ios-app-review-plugin 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.
Files changed (205) hide show
  1. package/.claude/settings.local.json +42 -0
  2. package/.github/actions/ios-review/action.yml +106 -0
  3. package/.github/workflows/ci.yml +103 -0
  4. package/.github/workflows/publish.yml +57 -0
  5. package/CHANGELOG.md +66 -0
  6. package/CONTRIBUTING.md +175 -0
  7. package/LICENSE +21 -0
  8. package/README.md +205 -0
  9. package/bitrise/step.sh +128 -0
  10. package/bitrise/step.yml +101 -0
  11. package/dist/analyzer.d.ts.map +1 -0
  12. package/dist/analyzers/asc-iap.d.ts.map +1 -0
  13. package/dist/analyzers/asc-metadata.d.ts.map +1 -0
  14. package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
  15. package/dist/analyzers/asc-version.d.ts.map +1 -0
  16. package/dist/analyzers/code-scanner.d.ts.map +1 -0
  17. package/dist/analyzers/deprecated-api.d.ts.map +1 -0
  18. package/dist/analyzers/entitlements.d.ts.map +1 -0
  19. package/dist/analyzers/index.d.ts.map +1 -0
  20. package/dist/analyzers/info-plist.d.ts.map +1 -0
  21. package/dist/analyzers/privacy.d.ts.map +1 -0
  22. package/dist/analyzers/private-api.d.ts.map +1 -0
  23. package/dist/analyzers/security.d.ts.map +1 -0
  24. package/dist/analyzers/ui-ux.d.ts.map +1 -0
  25. package/dist/asc/auth.d.ts.map +1 -0
  26. package/dist/asc/client.d.ts.map +1 -0
  27. package/dist/asc/endpoints/apps.d.ts.map +1 -0
  28. package/dist/asc/endpoints/iap.d.ts.map +1 -0
  29. package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
  30. package/dist/asc/endpoints/versions.d.ts.map +1 -0
  31. package/dist/asc/errors.d.ts.map +1 -0
  32. package/dist/asc/index.d.ts.map +1 -0
  33. package/dist/asc/types.d.ts.map +1 -0
  34. package/dist/badge/generator.d.ts.map +1 -0
  35. package/dist/badge/index.d.ts.map +1 -0
  36. package/dist/badge/types.d.ts.map +1 -0
  37. package/dist/cache/file-cache.d.ts.map +1 -0
  38. package/dist/cache/index.d.ts.map +1 -0
  39. package/dist/cache/types.d.ts.map +1 -0
  40. package/dist/cli/commands/help.d.ts.map +1 -0
  41. package/dist/cli/commands/scan.d.ts.map +1 -0
  42. package/dist/cli/commands/version.d.ts.map +1 -0
  43. package/dist/cli/index.d.ts.map +1 -0
  44. package/dist/cli/types.d.ts.map +1 -0
  45. package/dist/git/diff.d.ts.map +1 -0
  46. package/dist/git/index.d.ts.map +1 -0
  47. package/dist/git/types.d.ts.map +1 -0
  48. package/dist/guidelines/database.d.ts.map +1 -0
  49. package/dist/guidelines/index.d.ts.map +1 -0
  50. package/dist/guidelines/matcher.d.ts.map +1 -0
  51. package/dist/guidelines/types.d.ts.map +1 -0
  52. package/dist/history/comparator.d.ts.map +1 -0
  53. package/dist/history/index.d.ts.map +1 -0
  54. package/dist/history/store.d.ts.map +1 -0
  55. package/dist/history/types.d.ts.map +1 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +994 -0
  58. package/dist/parsers/index.d.ts.map +1 -0
  59. package/dist/parsers/plist.d.ts.map +1 -0
  60. package/dist/parsers/xcodeproj.d.ts.map +1 -0
  61. package/dist/progress/index.d.ts.map +1 -0
  62. package/dist/progress/reporter.d.ts.map +1 -0
  63. package/dist/progress/types.d.ts.map +1 -0
  64. package/dist/reports/html.d.ts.map +1 -0
  65. package/dist/reports/index.d.ts.map +1 -0
  66. package/dist/reports/json.d.ts.map +1 -0
  67. package/dist/reports/markdown.d.ts.map +1 -0
  68. package/dist/reports/types.d.ts.map +1 -0
  69. package/dist/rules/engine.d.ts.map +1 -0
  70. package/dist/rules/index.d.ts.map +1 -0
  71. package/dist/rules/loader.d.ts.map +1 -0
  72. package/dist/rules/types.d.ts.map +1 -0
  73. package/dist/types/index.d.ts.map +1 -0
  74. package/docs/ANALYZERS.md +237 -0
  75. package/docs/API.md +308 -0
  76. package/docs/BADGES.md +130 -0
  77. package/docs/CI_CD.md +283 -0
  78. package/docs/CLI.md +140 -0
  79. package/docs/REPORTS.md +212 -0
  80. package/docs/ROADMAP.md +267 -0
  81. package/docs/RULES.md +182 -0
  82. package/docs/SECURITY.md +89 -0
  83. package/docs/TROUBLESHOOTING.md +227 -0
  84. package/docs/tutorials/ASC_SETUP.md +188 -0
  85. package/docs/tutorials/CI_INTEGRATION.md +292 -0
  86. package/docs/tutorials/CUSTOM_RULES.md +291 -0
  87. package/docs/tutorials/GETTING_STARTED.md +226 -0
  88. package/docs/video-scripts/01-introduction.md +106 -0
  89. package/docs/video-scripts/02-cli-usage.md +120 -0
  90. package/docs/video-scripts/03-ci-integration.md +198 -0
  91. package/eslint.config.js +33 -0
  92. package/examples/.ios-review-rules.json +82 -0
  93. package/examples/bitrise-workflow.yml +129 -0
  94. package/examples/fastlane-lane.rb +71 -0
  95. package/examples/github-action.yml +147 -0
  96. package/fastlane/Fastfile.example +114 -0
  97. package/fastlane/README.md +99 -0
  98. package/jest.config.js +36 -0
  99. package/package.json +65 -0
  100. package/scripts/benchmark.ts +112 -0
  101. package/scripts/debug-parser.ts +37 -0
  102. package/scripts/debug-pbxproj.ts +36 -0
  103. package/scripts/debug-specific.ts +47 -0
  104. package/scripts/test-analyze.ts +67 -0
  105. package/scripts/xcode-cloud-review.sh +167 -0
  106. package/src/analyzer.ts +227 -0
  107. package/src/analyzers/asc-iap.ts +300 -0
  108. package/src/analyzers/asc-metadata.ts +326 -0
  109. package/src/analyzers/asc-screenshots.ts +310 -0
  110. package/src/analyzers/asc-version.ts +368 -0
  111. package/src/analyzers/code-scanner.ts +408 -0
  112. package/src/analyzers/deprecated-api.ts +390 -0
  113. package/src/analyzers/entitlements.ts +345 -0
  114. package/src/analyzers/index.ts +12 -0
  115. package/src/analyzers/info-plist.ts +409 -0
  116. package/src/analyzers/privacy.ts +376 -0
  117. package/src/analyzers/private-api.ts +377 -0
  118. package/src/analyzers/security.ts +327 -0
  119. package/src/analyzers/ui-ux.ts +509 -0
  120. package/src/asc/auth.ts +204 -0
  121. package/src/asc/client.ts +258 -0
  122. package/src/asc/endpoints/apps.ts +115 -0
  123. package/src/asc/endpoints/iap.ts +171 -0
  124. package/src/asc/endpoints/screenshots.ts +164 -0
  125. package/src/asc/endpoints/versions.ts +174 -0
  126. package/src/asc/errors.ts +109 -0
  127. package/src/asc/index.ts +108 -0
  128. package/src/asc/types.ts +369 -0
  129. package/src/badge/generator.ts +48 -0
  130. package/src/badge/index.ts +2 -0
  131. package/src/badge/types.ts +5 -0
  132. package/src/cache/file-cache.ts +75 -0
  133. package/src/cache/index.ts +2 -0
  134. package/src/cache/types.ts +10 -0
  135. package/src/cli/commands/help.ts +41 -0
  136. package/src/cli/commands/scan.ts +44 -0
  137. package/src/cli/commands/version.ts +12 -0
  138. package/src/cli/index.ts +92 -0
  139. package/src/cli/types.ts +17 -0
  140. package/src/git/diff.ts +21 -0
  141. package/src/git/index.ts +2 -0
  142. package/src/git/types.ts +5 -0
  143. package/src/guidelines/database.ts +344 -0
  144. package/src/guidelines/index.ts +4 -0
  145. package/src/guidelines/matcher.ts +84 -0
  146. package/src/guidelines/types.ts +28 -0
  147. package/src/history/comparator.ts +114 -0
  148. package/src/history/index.ts +3 -0
  149. package/src/history/store.ts +135 -0
  150. package/src/history/types.ts +40 -0
  151. package/src/index.ts +1113 -0
  152. package/src/parsers/index.ts +3 -0
  153. package/src/parsers/plist.ts +253 -0
  154. package/src/parsers/xcodeproj.ts +265 -0
  155. package/src/progress/index.ts +2 -0
  156. package/src/progress/reporter.ts +65 -0
  157. package/src/progress/types.ts +9 -0
  158. package/src/reports/html.ts +322 -0
  159. package/src/reports/index.ts +20 -0
  160. package/src/reports/json.ts +92 -0
  161. package/src/reports/markdown.ts +187 -0
  162. package/src/reports/types.ts +26 -0
  163. package/src/rules/engine.ts +121 -0
  164. package/src/rules/index.ts +3 -0
  165. package/src/rules/loader.ts +83 -0
  166. package/src/rules/types.ts +25 -0
  167. package/src/types/index.ts +247 -0
  168. package/tests/analyzer.test.ts +142 -0
  169. package/tests/analyzers/asc-iap.test.ts +228 -0
  170. package/tests/analyzers/asc-metadata.test.ts +210 -0
  171. package/tests/analyzers/asc-screenshots.test.ts +135 -0
  172. package/tests/analyzers/asc-version.test.ts +259 -0
  173. package/tests/analyzers/code-scanner.test.ts +745 -0
  174. package/tests/analyzers/deprecated-api.test.ts +286 -0
  175. package/tests/analyzers/entitlements.test.ts +411 -0
  176. package/tests/analyzers/info-plist.test.ts +148 -0
  177. package/tests/analyzers/privacy.test.ts +623 -0
  178. package/tests/analyzers/private-api.test.ts +255 -0
  179. package/tests/analyzers/security.test.ts +300 -0
  180. package/tests/analyzers/ui-ux.test.ts +357 -0
  181. package/tests/asc/auth.test.ts +189 -0
  182. package/tests/asc/client.test.ts +207 -0
  183. package/tests/asc/endpoints.test.ts +1359 -0
  184. package/tests/badge/generator.test.ts +73 -0
  185. package/tests/cache/file-cache.test.ts +124 -0
  186. package/tests/cli/cli-index.test.ts +510 -0
  187. package/tests/cli/commands.test.ts +67 -0
  188. package/tests/cli/scan.test.ts +152 -0
  189. package/tests/git/diff.test.ts +69 -0
  190. package/tests/guidelines/matcher.test.ts +209 -0
  191. package/tests/history/comparator.test.ts +272 -0
  192. package/tests/history/store.test.ts +200 -0
  193. package/tests/integration/cli.test.ts +95 -0
  194. package/tests/integration/e2e.test.ts +130 -0
  195. package/tests/parsers/plist.test.ts +240 -0
  196. package/tests/parsers/xcodeproj.test.ts +289 -0
  197. package/tests/progress/reporter.test.ts +117 -0
  198. package/tests/reports/html.test.ts +176 -0
  199. package/tests/reports/json.test.ts +235 -0
  200. package/tests/reports/markdown.test.ts +196 -0
  201. package/tests/rules/engine.test.ts +229 -0
  202. package/tests/rules/loader.test.ts +187 -0
  203. package/tests/setup.ts +15 -0
  204. package/tsconfig.json +27 -0
  205. package/tsconfig.test.json +9 -0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * App Store Connect Metadata Analyzer
3
+ *
4
+ * Validates app metadata from ASC including app name, subtitle,
5
+ * description, keywords, URLs, and checks for placeholder text.
6
+ */
7
+
8
+ import type {
9
+ Analyzer,
10
+ AnalysisResult,
11
+ AnalyzerOptions,
12
+ Issue,
13
+ XcodeProject,
14
+ } from '../types/index.js';
15
+ import {
16
+ hasCredentials,
17
+ getAppWithInfo,
18
+ isASCError,
19
+ type AppInfoLocalization,
20
+ } from '../asc/index.js';
21
+
22
+ /**
23
+ * Metadata length limits from App Store Connect
24
+ */
25
+ const LIMITS = {
26
+ appName: 30,
27
+ subtitle: 30,
28
+ description: 4000,
29
+ keywords: 100,
30
+ promotionalText: 170,
31
+ whatsNew: 4000,
32
+ };
33
+
34
+ /**
35
+ * Placeholder text patterns
36
+ */
37
+ const PLACEHOLDER_PATTERNS = [
38
+ /lorem\s+ipsum/i,
39
+ /^todo/i,
40
+ /^fixme/i,
41
+ /^\[.*\]$/,
42
+ /^<.*>$/,
43
+ /^placeholder/i,
44
+ /your\s+(app\s+)?description/i,
45
+ /enter\s+(your\s+)?description/i,
46
+ /add\s+(your\s+)?description/i,
47
+ /sample\s+text/i,
48
+ /example\s+text/i,
49
+ /test\s+(app|description)/i,
50
+ ];
51
+
52
+ /**
53
+ * Check if text contains placeholder content
54
+ */
55
+ function isPlaceholder(text: string): boolean {
56
+ return PLACEHOLDER_PATTERNS.some((pattern) => pattern.test(text.trim()));
57
+ }
58
+
59
+ /**
60
+ * Validate a URL format
61
+ */
62
+ function isValidUrl(url: string): boolean {
63
+ try {
64
+ const parsed = new URL(url);
65
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:';
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ export class ASCMetadataAnalyzer implements Analyzer {
72
+ name = 'ASC Metadata Analyzer';
73
+ description = 'Validates app metadata in App Store Connect';
74
+
75
+ async analyze(project: XcodeProject, options?: AnalyzerOptions): Promise<AnalysisResult> {
76
+ const startTime = Date.now();
77
+ const issues: Issue[] = [];
78
+
79
+ // Check if credentials are configured
80
+ if (!hasCredentials()) {
81
+ issues.push({
82
+ id: 'asc-credentials-not-configured',
83
+ title: 'App Store Connect credentials not configured',
84
+ description:
85
+ 'ASC credentials are not configured. Set ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY_PATH (or ASC_PRIVATE_KEY) environment variables to enable ASC validation.',
86
+ severity: 'info',
87
+ category: 'metadata',
88
+ suggestion:
89
+ 'See documentation for setting up App Store Connect API credentials.',
90
+ });
91
+
92
+ return {
93
+ analyzer: this.name,
94
+ passed: true,
95
+ issues,
96
+ duration: Date.now() - startTime,
97
+ };
98
+ }
99
+
100
+ // Get bundle ID from project
101
+ const bundleId = options?.bundleId ?? this.getBundleIdFromProject(project);
102
+ if (!bundleId) {
103
+ issues.push({
104
+ id: 'asc-no-bundle-id',
105
+ title: 'No bundle ID found',
106
+ description: 'Could not determine bundle ID from project to query App Store Connect.',
107
+ severity: 'warning',
108
+ category: 'metadata',
109
+ });
110
+
111
+ return {
112
+ analyzer: this.name,
113
+ passed: true,
114
+ issues,
115
+ duration: Date.now() - startTime,
116
+ };
117
+ }
118
+
119
+ try {
120
+ const appData = await getAppWithInfo(bundleId);
121
+
122
+ // Validate app info localizations
123
+ for (const localization of appData.localizations) {
124
+ const locIssues = this.validateLocalization(localization, appData.app.attributes.primaryLocale);
125
+ issues.push(...locIssues);
126
+ }
127
+
128
+ // Check for missing primary locale
129
+ const primaryLocale = appData.app.attributes.primaryLocale;
130
+ const hasPrimaryLocale = appData.localizations.some(
131
+ (loc) => loc.attributes.locale === primaryLocale
132
+ );
133
+
134
+ if (!hasPrimaryLocale && appData.localizations.length > 0) {
135
+ issues.push({
136
+ id: 'asc-missing-primary-locale',
137
+ title: 'Missing primary locale metadata',
138
+ description: `App info localization for primary locale "${primaryLocale}" not found.`,
139
+ severity: 'warning',
140
+ category: 'metadata',
141
+ });
142
+ }
143
+ } catch (error) {
144
+ if (isASCError(error)) {
145
+ issues.push({
146
+ id: error.code,
147
+ title: error.name,
148
+ description: error.message,
149
+ severity: 'error',
150
+ category: 'metadata',
151
+ });
152
+ } else {
153
+ issues.push({
154
+ id: 'asc-api-error',
155
+ title: 'App Store Connect API Error',
156
+ description: error instanceof Error ? error.message : String(error),
157
+ severity: 'error',
158
+ category: 'metadata',
159
+ });
160
+ }
161
+ }
162
+
163
+ return {
164
+ analyzer: this.name,
165
+ passed: issues.filter((i) => i.severity === 'error').length === 0,
166
+ issues,
167
+ duration: Date.now() - startTime,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Validate a single localization
173
+ */
174
+ private validateLocalization(localization: AppInfoLocalization, primaryLocale: string): Issue[] {
175
+ const issues: Issue[] = [];
176
+ const locale = localization.attributes.locale;
177
+ const isPrimary = locale === primaryLocale;
178
+
179
+ // Validate app name
180
+ const name = localization.attributes.name;
181
+ if (name) {
182
+ if (name.length > LIMITS.appName) {
183
+ issues.push({
184
+ id: 'asc-name-too-long',
185
+ title: `App name too long (${locale})`,
186
+ description: `App name is ${name.length} characters, maximum is ${LIMITS.appName}.`,
187
+ severity: 'error',
188
+ category: 'metadata',
189
+ suggestion: `Shorten the app name to ${LIMITS.appName} characters or less.`,
190
+ });
191
+ }
192
+
193
+ if (isPlaceholder(name)) {
194
+ issues.push({
195
+ id: 'asc-name-placeholder',
196
+ title: `Placeholder app name detected (${locale})`,
197
+ description: `App name "${name}" appears to be placeholder text.`,
198
+ severity: 'error',
199
+ category: 'metadata',
200
+ guideline: 'Guideline 2.3.7 - Accurate Metadata',
201
+ });
202
+ }
203
+ } else if (isPrimary) {
204
+ issues.push({
205
+ id: 'asc-missing-name',
206
+ title: 'Missing app name',
207
+ description: `App name is not set for primary locale "${locale}".`,
208
+ severity: 'error',
209
+ category: 'metadata',
210
+ });
211
+ }
212
+
213
+ // Validate subtitle
214
+ const subtitle = localization.attributes.subtitle;
215
+ if (subtitle) {
216
+ if (subtitle.length > LIMITS.subtitle) {
217
+ issues.push({
218
+ id: 'asc-subtitle-too-long',
219
+ title: `Subtitle too long (${locale})`,
220
+ description: `Subtitle is ${subtitle.length} characters, maximum is ${LIMITS.subtitle}.`,
221
+ severity: 'error',
222
+ category: 'metadata',
223
+ });
224
+ }
225
+
226
+ if (isPlaceholder(subtitle)) {
227
+ issues.push({
228
+ id: 'asc-subtitle-placeholder',
229
+ title: `Placeholder subtitle detected (${locale})`,
230
+ description: `Subtitle "${subtitle}" appears to be placeholder text.`,
231
+ severity: 'error',
232
+ category: 'metadata',
233
+ });
234
+ }
235
+ }
236
+
237
+ // Validate privacy policy URL
238
+ const privacyUrl = localization.attributes.privacyPolicyUrl;
239
+ if (!privacyUrl && isPrimary) {
240
+ issues.push({
241
+ id: 'asc-missing-privacy-policy',
242
+ title: 'Missing privacy policy URL',
243
+ description: 'Privacy policy URL is required for App Store submission.',
244
+ severity: 'error',
245
+ category: 'metadata',
246
+ guideline: 'Guideline 5.1.1 - Data Collection and Storage',
247
+ suggestion: 'Add a privacy policy URL to your app metadata.',
248
+ });
249
+ } else if (privacyUrl && !isValidUrl(privacyUrl)) {
250
+ issues.push({
251
+ id: 'asc-invalid-privacy-url',
252
+ title: `Invalid privacy policy URL (${locale})`,
253
+ description: `Privacy policy URL "${privacyUrl}" is not a valid URL.`,
254
+ severity: 'error',
255
+ category: 'metadata',
256
+ });
257
+ }
258
+
259
+ return issues;
260
+ }
261
+
262
+ /**
263
+ * Standalone metadata validation by bundle ID
264
+ */
265
+ async validateByBundleId(bundleId: string): Promise<AnalysisResult> {
266
+ const startTime = Date.now();
267
+ const issues: Issue[] = [];
268
+
269
+ if (!hasCredentials()) {
270
+ return {
271
+ analyzer: this.name,
272
+ passed: false,
273
+ issues: [
274
+ {
275
+ id: 'asc-credentials-not-configured',
276
+ title: 'App Store Connect credentials not configured',
277
+ description:
278
+ 'Set ASC_KEY_ID, ASC_ISSUER_ID, and ASC_PRIVATE_KEY_PATH environment variables.',
279
+ severity: 'error',
280
+ category: 'metadata',
281
+ },
282
+ ],
283
+ duration: Date.now() - startTime,
284
+ };
285
+ }
286
+
287
+ try {
288
+ const appData = await getAppWithInfo(bundleId);
289
+
290
+ for (const localization of appData.localizations) {
291
+ const locIssues = this.validateLocalization(
292
+ localization,
293
+ appData.app.attributes.primaryLocale
294
+ );
295
+ issues.push(...locIssues);
296
+ }
297
+ } catch (error) {
298
+ if (isASCError(error)) {
299
+ issues.push({
300
+ id: error.code,
301
+ title: error.name,
302
+ description: error.message,
303
+ severity: 'error',
304
+ category: 'metadata',
305
+ });
306
+ } else {
307
+ throw error;
308
+ }
309
+ }
310
+
311
+ return {
312
+ analyzer: this.name,
313
+ passed: issues.filter((i) => i.severity === 'error').length === 0,
314
+ issues,
315
+ duration: Date.now() - startTime,
316
+ };
317
+ }
318
+
319
+ /**
320
+ * Get bundle ID from project targets
321
+ */
322
+ private getBundleIdFromProject(project: XcodeProject): string | undefined {
323
+ const appTarget = project.targets.find((t) => t.type === 'application');
324
+ return appTarget?.bundleIdentifier;
325
+ }
326
+ }
@@ -0,0 +1,310 @@
1
+ /**
2
+ * App Store Connect Screenshot Analyzer
3
+ *
4
+ * Validates screenshots in ASC including required device sizes,
5
+ * screenshot counts, processing status, and localized presence.
6
+ */
7
+
8
+ import type {
9
+ Analyzer,
10
+ AnalysisResult,
11
+ AnalyzerOptions,
12
+ Issue,
13
+ XcodeProject,
14
+ } from '../types/index.js';
15
+ import {
16
+ hasCredentials,
17
+ getAppByBundleId,
18
+ getEditableVersion,
19
+ getVersionLocalizations,
20
+ getScreenshotSetsWithScreenshots,
21
+ REQUIRED_IPHONE_DISPLAY_TYPES,
22
+ REQUIRED_IPAD_DISPLAY_TYPES,
23
+ getDisplayTypeDescription,
24
+ validateScreenshotSet,
25
+ isASCError,
26
+ type ScreenshotDisplayType,
27
+ type AppStoreVersionLocalization,
28
+ } from '../asc/index.js';
29
+
30
+ /**
31
+ * Screenshot requirements
32
+ */
33
+ const MAX_SCREENSHOTS = 10;
34
+
35
+ export class ASCScreenshotAnalyzer implements Analyzer {
36
+ name = 'ASC Screenshot Analyzer';
37
+ description = 'Validates screenshots in App Store Connect';
38
+
39
+ async analyze(project: XcodeProject, options?: AnalyzerOptions): Promise<AnalysisResult> {
40
+ const startTime = Date.now();
41
+ const issues: Issue[] = [];
42
+
43
+ if (!hasCredentials()) {
44
+ issues.push({
45
+ id: 'asc-credentials-not-configured',
46
+ title: 'App Store Connect credentials not configured',
47
+ description:
48
+ 'ASC credentials are not configured. Set environment variables to enable screenshot validation.',
49
+ severity: 'info',
50
+ category: 'screenshots',
51
+ });
52
+
53
+ return {
54
+ analyzer: this.name,
55
+ passed: true,
56
+ issues,
57
+ duration: Date.now() - startTime,
58
+ };
59
+ }
60
+
61
+ const bundleId = options?.bundleId ?? this.getBundleIdFromProject(project);
62
+ if (!bundleId) {
63
+ issues.push({
64
+ id: 'asc-no-bundle-id',
65
+ title: 'No bundle ID found',
66
+ description: 'Could not determine bundle ID from project.',
67
+ severity: 'warning',
68
+ category: 'screenshots',
69
+ });
70
+
71
+ return {
72
+ analyzer: this.name,
73
+ passed: true,
74
+ issues,
75
+ duration: Date.now() - startTime,
76
+ };
77
+ }
78
+
79
+ try {
80
+ const screenshotIssues = await this.validateScreenshotsForBundleId(bundleId);
81
+ issues.push(...screenshotIssues);
82
+ } catch (error) {
83
+ if (isASCError(error)) {
84
+ issues.push({
85
+ id: error.code,
86
+ title: error.name,
87
+ description: error.message,
88
+ severity: 'error',
89
+ category: 'screenshots',
90
+ });
91
+ } else {
92
+ issues.push({
93
+ id: 'asc-api-error',
94
+ title: 'App Store Connect API Error',
95
+ description: error instanceof Error ? error.message : String(error),
96
+ severity: 'error',
97
+ category: 'screenshots',
98
+ });
99
+ }
100
+ }
101
+
102
+ return {
103
+ analyzer: this.name,
104
+ passed: issues.filter((i) => i.severity === 'error').length === 0,
105
+ issues,
106
+ duration: Date.now() - startTime,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Validate screenshots for a bundle ID
112
+ */
113
+ async validateByBundleId(bundleId: string): Promise<AnalysisResult> {
114
+ const startTime = Date.now();
115
+ const issues: Issue[] = [];
116
+
117
+ if (!hasCredentials()) {
118
+ return {
119
+ analyzer: this.name,
120
+ passed: false,
121
+ issues: [
122
+ {
123
+ id: 'asc-credentials-not-configured',
124
+ title: 'App Store Connect credentials not configured',
125
+ description: 'Set ASC environment variables to enable validation.',
126
+ severity: 'error',
127
+ category: 'screenshots',
128
+ },
129
+ ],
130
+ duration: Date.now() - startTime,
131
+ };
132
+ }
133
+
134
+ try {
135
+ const screenshotIssues = await this.validateScreenshotsForBundleId(bundleId);
136
+ issues.push(...screenshotIssues);
137
+ } catch (error) {
138
+ if (isASCError(error)) {
139
+ issues.push({
140
+ id: error.code,
141
+ title: error.name,
142
+ description: error.message,
143
+ severity: 'error',
144
+ category: 'screenshots',
145
+ });
146
+ } else {
147
+ throw error;
148
+ }
149
+ }
150
+
151
+ return {
152
+ analyzer: this.name,
153
+ passed: issues.filter((i) => i.severity === 'error').length === 0,
154
+ issues,
155
+ duration: Date.now() - startTime,
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Internal screenshot validation
161
+ */
162
+ private async validateScreenshotsForBundleId(bundleId: string): Promise<Issue[]> {
163
+ const issues: Issue[] = [];
164
+
165
+ const app = await getAppByBundleId(bundleId);
166
+ const version = await getEditableVersion(app.id);
167
+
168
+ if (!version) {
169
+ issues.push({
170
+ id: 'asc-no-editable-version',
171
+ title: 'No editable version found',
172
+ description:
173
+ 'No app version in editable state (PREPARE_FOR_SUBMISSION, etc.) found in App Store Connect.',
174
+ severity: 'info',
175
+ category: 'screenshots',
176
+ });
177
+ return issues;
178
+ }
179
+
180
+ const localizations = await getVersionLocalizations(version.id);
181
+
182
+ if (localizations.length === 0) {
183
+ issues.push({
184
+ id: 'asc-no-localizations',
185
+ title: 'No version localizations found',
186
+ description: 'No localizations configured for the current app version.',
187
+ severity: 'warning',
188
+ category: 'screenshots',
189
+ });
190
+ return issues;
191
+ }
192
+
193
+ // Check each localization
194
+ for (const localization of localizations) {
195
+ const locIssues = await this.validateLocalizationScreenshots(localization);
196
+ issues.push(...locIssues);
197
+ }
198
+
199
+ return issues;
200
+ }
201
+
202
+ /**
203
+ * Validate screenshots for a single localization
204
+ */
205
+ private async validateLocalizationScreenshots(
206
+ localization: AppStoreVersionLocalization
207
+ ): Promise<Issue[]> {
208
+ const issues: Issue[] = [];
209
+ const locale = localization.attributes.locale;
210
+
211
+ const screenshotSets = await getScreenshotSetsWithScreenshots(localization.id);
212
+
213
+ // Track which required types are present
214
+ const presentTypes = new Set<ScreenshotDisplayType>(
215
+ screenshotSets.map((s) => s.set.attributes.screenshotDisplayType)
216
+ );
217
+
218
+ // Check required iPhone sizes
219
+ const missingIPhoneTypes = REQUIRED_IPHONE_DISPLAY_TYPES.filter(
220
+ (type) => !presentTypes.has(type)
221
+ );
222
+
223
+ if (missingIPhoneTypes.length > 0) {
224
+ // At least one iPhone size is required
225
+ const hasAnyIPhoneScreenshots = REQUIRED_IPHONE_DISPLAY_TYPES.some((type) =>
226
+ presentTypes.has(type)
227
+ );
228
+
229
+ if (!hasAnyIPhoneScreenshots) {
230
+ issues.push({
231
+ id: 'asc-missing-iphone-screenshots',
232
+ title: `Missing iPhone screenshots (${locale})`,
233
+ description: `No iPhone screenshots found. At least one of these sizes is required: ${missingIPhoneTypes.map(getDisplayTypeDescription).join(', ')}`,
234
+ severity: 'error',
235
+ category: 'screenshots',
236
+ suggestion: 'Upload screenshots for at least iPhone 6.5" or 5.5" display.',
237
+ });
238
+ } else {
239
+ issues.push({
240
+ id: 'asc-incomplete-iphone-screenshots',
241
+ title: `Incomplete iPhone screenshot sizes (${locale})`,
242
+ description: `Missing screenshots for: ${missingIPhoneTypes.map(getDisplayTypeDescription).join(', ')}`,
243
+ severity: 'warning',
244
+ category: 'screenshots',
245
+ });
246
+ }
247
+ }
248
+
249
+ // Check iPad sizes if app supports iPad
250
+ const missingIPadTypes = REQUIRED_IPAD_DISPLAY_TYPES.filter(
251
+ (type) => !presentTypes.has(type)
252
+ );
253
+
254
+ if (missingIPadTypes.length === REQUIRED_IPAD_DISPLAY_TYPES.length) {
255
+ // No iPad screenshots - this might be intentional if app is iPhone-only
256
+ issues.push({
257
+ id: 'asc-no-ipad-screenshots',
258
+ title: `No iPad screenshots (${locale})`,
259
+ description:
260
+ 'No iPad screenshots found. If your app supports iPad, screenshots are required for iPad Pro 12.9".',
261
+ severity: 'info',
262
+ category: 'screenshots',
263
+ });
264
+ }
265
+
266
+ // Validate each screenshot set
267
+ for (const { set, screenshots } of screenshotSets) {
268
+ const validation = validateScreenshotSet(set, screenshots);
269
+
270
+ // Check count
271
+ if (screenshots.length === 0) {
272
+ issues.push({
273
+ id: 'asc-empty-screenshot-set',
274
+ title: `Empty screenshot set (${locale})`,
275
+ description: `Screenshot set for ${getDisplayTypeDescription(set.attributes.screenshotDisplayType)} has no screenshots.`,
276
+ severity: 'warning',
277
+ category: 'screenshots',
278
+ });
279
+ } else if (screenshots.length > MAX_SCREENSHOTS) {
280
+ issues.push({
281
+ id: 'asc-too-many-screenshots',
282
+ title: `Too many screenshots (${locale})`,
283
+ description: `Screenshot set for ${getDisplayTypeDescription(set.attributes.screenshotDisplayType)} has ${screenshots.length} screenshots (max ${MAX_SCREENSHOTS}).`,
284
+ severity: 'error',
285
+ category: 'screenshots',
286
+ });
287
+ }
288
+
289
+ // Check processing errors
290
+ if (validation.hasProcessingErrors) {
291
+ for (const issue of validation.issues) {
292
+ issues.push({
293
+ id: 'asc-screenshot-processing-error',
294
+ title: `Screenshot processing error (${locale})`,
295
+ description: issue,
296
+ severity: 'error',
297
+ category: 'screenshots',
298
+ });
299
+ }
300
+ }
301
+ }
302
+
303
+ return issues;
304
+ }
305
+
306
+ private getBundleIdFromProject(project: XcodeProject): string | undefined {
307
+ const appTarget = project.targets.find((t) => t.type === 'application');
308
+ return appTarget?.bundleIdentifier;
309
+ }
310
+ }