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,3 @@
1
+ export { parsePlist, parsePbxproj, fileExists, readFile } from './plist.js';
2
+ export { parseXcodeProject } from './xcodeproj.js';
3
+ export type { PbxProject, PbxObject } from './plist.js';
@@ -0,0 +1,253 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as plist from 'plist';
3
+
4
+ /**
5
+ * Parse a plist file (XML or binary format)
6
+ */
7
+ export async function parsePlist<T = Record<string, unknown>>(filePath: string): Promise<T> {
8
+ const content = await fs.readFile(filePath, 'utf-8');
9
+
10
+ // Try XML plist first
11
+ try {
12
+ return plist.parse(content) as T;
13
+ } catch {
14
+ // If XML parsing fails, it might be binary plist
15
+ // The plist library handles both, but let's try with buffer
16
+ const buffer = await fs.readFile(filePath);
17
+ return plist.parse(buffer.toString('utf-8')) as T;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Check if a file exists
23
+ */
24
+ export async function fileExists(filePath: string): Promise<boolean> {
25
+ try {
26
+ await fs.access(filePath);
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Read file contents as string
35
+ */
36
+ export async function readFile(filePath: string): Promise<string> {
37
+ return fs.readFile(filePath, 'utf-8');
38
+ }
39
+
40
+ /**
41
+ * Parse a pbxproj file (OpenStep plist format)
42
+ * This is a simplified parser for the Xcode project format
43
+ */
44
+ export function parsePbxproj(content: string): PbxProject {
45
+ const result: PbxProject = {
46
+ archiveVersion: '',
47
+ objectVersion: '',
48
+ rootObject: '',
49
+ objects: {},
50
+ };
51
+
52
+ // Extract archive version
53
+ const archiveMatch = content.match(/archiveVersion\s*=\s*(\d+)/);
54
+ if (archiveMatch?.[1]) {
55
+ result.archiveVersion = archiveMatch[1];
56
+ }
57
+
58
+ // Extract object version
59
+ const objectMatch = content.match(/objectVersion\s*=\s*(\d+)/);
60
+ if (objectMatch?.[1]) {
61
+ result.objectVersion = objectMatch[1];
62
+ }
63
+
64
+ // Extract root object
65
+ const rootMatch = content.match(/rootObject\s*=\s*([A-F0-9]+)/);
66
+ if (rootMatch?.[1]) {
67
+ result.rootObject = rootMatch[1];
68
+ }
69
+
70
+ // Parse objects section
71
+ const objectsMatch = content.match(/objects\s*=\s*\{([\s\S]*)\};\s*rootObject/);
72
+ if (objectsMatch?.[1]) {
73
+ result.objects = parseObjects(objectsMatch[1]);
74
+ }
75
+
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Parse the objects section of a pbxproj file
81
+ */
82
+ function parseObjects(content: string): Record<string, PbxObject> {
83
+ const objects: Record<string, PbxObject> = {};
84
+
85
+ // Find each object by looking for ID = { pattern and matching braces
86
+ const idPattern = /([A-F0-9]{24})\s*(?:\/\*[^*]*\*\/)?\s*=\s*\{/g;
87
+ let idMatch: RegExpExecArray | null;
88
+
89
+ while ((idMatch = idPattern.exec(content)) !== null) {
90
+ const id = idMatch[1];
91
+ const startBrace = idMatch.index + idMatch[0].length - 1;
92
+
93
+ // Find matching closing brace
94
+ let depth = 1;
95
+ let pos = startBrace + 1;
96
+ while (pos < content.length && depth > 0) {
97
+ const char = content[pos];
98
+ if (char === '{') {
99
+ depth++;
100
+ } else if (char === '}') {
101
+ depth--;
102
+ }
103
+ pos++;
104
+ }
105
+
106
+ if (depth === 0 && id) {
107
+ const objectContent = content.substring(startBrace + 1, pos - 1);
108
+ const parsedObj = parseObjectContent(objectContent);
109
+
110
+ // Only store if this object has a valid isa, or if we haven't seen this ID before
111
+ // This handles the case where an ID appears multiple times (e.g., in different sections)
112
+ if (parsedObj.isa || !objects[id]) {
113
+ // If the new object has isa, use it; otherwise keep the existing one
114
+ if (parsedObj.isa || !objects[id]?.isa) {
115
+ objects[id] = parsedObj;
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ return objects;
122
+ }
123
+
124
+ /**
125
+ * Parse individual object content
126
+ */
127
+ function parseObjectContent(content: string): PbxObject {
128
+ const obj: PbxObject = { isa: '' };
129
+
130
+ // Extract isa (object type)
131
+ const isaMatch = content.match(/isa\s*=\s*(\w+)/);
132
+ if (isaMatch?.[1]) {
133
+ obj.isa = isaMatch[1];
134
+ }
135
+
136
+ // Extract name
137
+ const nameMatch = content.match(/name\s*=\s*"?([^";]+)"?/);
138
+ if (nameMatch?.[1]) {
139
+ obj.name = nameMatch[1].trim();
140
+ }
141
+
142
+ // Extract path
143
+ const pathMatch = content.match(/path\s*=\s*"?([^";]+)"?/);
144
+ if (pathMatch?.[1]) {
145
+ obj.path = pathMatch[1].trim();
146
+ }
147
+
148
+ // Extract productType
149
+ const productTypeMatch = content.match(/productType\s*=\s*"([^"]+)"/);
150
+ if (productTypeMatch?.[1]) {
151
+ obj.productType = productTypeMatch[1];
152
+ }
153
+
154
+ // Extract buildConfigurationList
155
+ const buildConfigMatch = content.match(/buildConfigurationList\s*=\s*([A-F0-9]{24})/);
156
+ if (buildConfigMatch?.[1]) {
157
+ obj.buildConfigurationList = buildConfigMatch[1];
158
+ }
159
+
160
+ // Extract buildSettings (simplified)
161
+ const buildSettingsMatch = content.match(/buildSettings\s*=\s*\{([^}]+)\}/);
162
+ if (buildSettingsMatch?.[1]) {
163
+ obj.buildSettings = parseBuildSettings(buildSettingsMatch[1]);
164
+ }
165
+
166
+ // Extract files array
167
+ const filesMatch = content.match(/files\s*=\s*\(([^)]*)\)/);
168
+ if (filesMatch?.[1]) {
169
+ obj.files = parseArray(filesMatch[1]);
170
+ }
171
+
172
+ // Extract dependencies array
173
+ const depsMatch = content.match(/dependencies\s*=\s*\(([^)]*)\)/);
174
+ if (depsMatch?.[1]) {
175
+ obj.dependencies = parseArray(depsMatch[1]);
176
+ }
177
+
178
+ // Extract buildPhases array
179
+ const phasesMatch = content.match(/buildPhases\s*=\s*\(([^)]*)\)/);
180
+ if (phasesMatch?.[1]) {
181
+ obj.buildPhases = parseArray(phasesMatch[1]);
182
+ }
183
+
184
+ // Extract buildConfigurations array (for XCConfigurationList)
185
+ const configsMatch = content.match(/buildConfigurations\s*=\s*\(([^)]*)\)/);
186
+ if (configsMatch?.[1]) {
187
+ obj['buildConfigurations'] = parseArray(configsMatch[1]);
188
+ }
189
+
190
+ return obj;
191
+ }
192
+
193
+ /**
194
+ * Parse build settings
195
+ */
196
+ function parseBuildSettings(content: string): Record<string, string> {
197
+ const settings: Record<string, string> = {};
198
+ const settingRegex = /(\w+)\s*=\s*"?([^";]+)"?/g;
199
+ let match: RegExpExecArray | null;
200
+
201
+ while ((match = settingRegex.exec(content)) !== null) {
202
+ const key = match[1];
203
+ const value = match[2];
204
+ if (key && value) {
205
+ settings[key] = value.trim();
206
+ }
207
+ }
208
+
209
+ return settings;
210
+ }
211
+
212
+ /**
213
+ * Parse an array from pbxproj format
214
+ */
215
+ function parseArray(content: string): string[] {
216
+ const items: string[] = [];
217
+ const itemRegex = /([A-F0-9]{24})/g;
218
+ let match: RegExpExecArray | null;
219
+
220
+ while ((match = itemRegex.exec(content)) !== null) {
221
+ if (match[1]) {
222
+ items.push(match[1]);
223
+ }
224
+ }
225
+
226
+ return items;
227
+ }
228
+
229
+ /**
230
+ * PBX project structure
231
+ */
232
+ export interface PbxProject {
233
+ archiveVersion: string;
234
+ objectVersion: string;
235
+ rootObject: string;
236
+ objects: Record<string, PbxObject>;
237
+ }
238
+
239
+ /**
240
+ * PBX object (generic)
241
+ */
242
+ export interface PbxObject {
243
+ isa: string;
244
+ name?: string;
245
+ path?: string;
246
+ productType?: string;
247
+ buildConfigurationList?: string;
248
+ buildSettings?: Record<string, string>;
249
+ files?: string[];
250
+ dependencies?: string[];
251
+ buildPhases?: string[];
252
+ [key: string]: unknown;
253
+ }
@@ -0,0 +1,265 @@
1
+ import * as fs from 'fs/promises';
2
+ import * as path from 'path';
3
+ import { parsePbxproj, fileExists, type PbxProject, type PbxObject } from './plist.js';
4
+ import type { XcodeProject, XcodeTarget, TargetType } from '../types/index.js';
5
+
6
+ /**
7
+ * Parse an Xcode project or workspace
8
+ */
9
+ export async function parseXcodeProject(projectPath: string): Promise<XcodeProject> {
10
+ const stats = await fs.stat(projectPath);
11
+
12
+ if (stats.isDirectory()) {
13
+ if (projectPath.endsWith('.xcworkspace')) {
14
+ return parseWorkspace(projectPath);
15
+ } else if (projectPath.endsWith('.xcodeproj')) {
16
+ return parseProject(projectPath);
17
+ }
18
+ }
19
+
20
+ throw new Error(
21
+ `Invalid project path: ${projectPath}. Expected .xcodeproj or .xcworkspace directory.`
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Parse an .xcodeproj directory
27
+ */
28
+ async function parseProject(projectPath: string): Promise<XcodeProject> {
29
+ const pbxprojPath = path.join(projectPath, 'project.pbxproj');
30
+
31
+ if (!(await fileExists(pbxprojPath))) {
32
+ throw new Error(`project.pbxproj not found at ${pbxprojPath}`);
33
+ }
34
+
35
+ const content = await fs.readFile(pbxprojPath, 'utf-8');
36
+ const pbxProject = parsePbxproj(content);
37
+
38
+ const projectName = path.basename(projectPath, '.xcodeproj');
39
+ const basePath = path.dirname(projectPath);
40
+
41
+ // Extract targets
42
+ const targets = extractTargets(pbxProject, basePath);
43
+
44
+ // Extract configurations
45
+ const configurations = extractConfigurations(pbxProject);
46
+
47
+ return {
48
+ path: projectPath,
49
+ name: projectName,
50
+ targets,
51
+ configurations,
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Parse an .xcworkspace directory
57
+ */
58
+ async function parseWorkspace(workspacePath: string): Promise<XcodeProject> {
59
+ const contentsPath = path.join(workspacePath, 'contents.xcworkspacedata');
60
+
61
+ if (!(await fileExists(contentsPath))) {
62
+ throw new Error(`contents.xcworkspacedata not found at ${contentsPath}`);
63
+ }
64
+
65
+ const content = await fs.readFile(contentsPath, 'utf-8');
66
+
67
+ // Extract project references from workspace
68
+ const projectRefs = extractWorkspaceProjects(content);
69
+ const basePath = path.dirname(workspacePath);
70
+
71
+ // Find the main project (usually the one with the same name as the workspace)
72
+ const workspaceName = path.basename(workspacePath, '.xcworkspace');
73
+ let mainProjectPath: string | undefined;
74
+
75
+ for (const ref of projectRefs) {
76
+ const fullPath = path.resolve(basePath, ref);
77
+ if (ref.includes(workspaceName) && (await fileExists(fullPath))) {
78
+ mainProjectPath = fullPath;
79
+ break;
80
+ }
81
+ }
82
+
83
+ // Fallback to first project if no matching name
84
+ if (!mainProjectPath && projectRefs.length > 0 && projectRefs[0]) {
85
+ mainProjectPath = path.resolve(basePath, projectRefs[0]);
86
+ }
87
+
88
+ if (!mainProjectPath) {
89
+ throw new Error(`No projects found in workspace ${workspacePath}`);
90
+ }
91
+
92
+ // Parse the main project
93
+ const project = await parseProject(mainProjectPath);
94
+ project.path = workspacePath;
95
+ project.name = workspaceName;
96
+
97
+ return project;
98
+ }
99
+
100
+ /**
101
+ * Extract project paths from workspace data
102
+ */
103
+ function extractWorkspaceProjects(content: string): string[] {
104
+ const projects: string[] = [];
105
+ const locationRegex = /location\s*=\s*"group:([^"]+\.xcodeproj)"/g;
106
+ let match: RegExpExecArray | null;
107
+
108
+ while ((match = locationRegex.exec(content)) !== null) {
109
+ if (match[1]) {
110
+ projects.push(match[1]);
111
+ }
112
+ }
113
+
114
+ return projects;
115
+ }
116
+
117
+ /**
118
+ * Extract targets from a pbxproj
119
+ */
120
+ function extractTargets(pbxProject: PbxProject, basePath: string): XcodeTarget[] {
121
+ const targets: XcodeTarget[] = [];
122
+ const objects = pbxProject.objects;
123
+
124
+ // Find native targets
125
+ for (const obj of Object.values(objects)) {
126
+ if (obj.isa === 'PBXNativeTarget') {
127
+ const target = extractTarget(obj, objects, basePath);
128
+ if (target) {
129
+ targets.push(target);
130
+ }
131
+ }
132
+ }
133
+
134
+ return targets;
135
+ }
136
+
137
+ /**
138
+ * Extract a single target's information
139
+ */
140
+ function extractTarget(
141
+ targetObj: PbxObject,
142
+ objects: Record<string, PbxObject>,
143
+ basePath: string
144
+ ): XcodeTarget | null {
145
+ const name = targetObj.name ?? 'Unknown';
146
+ const type = mapProductType(targetObj.productType);
147
+
148
+ // Get build settings from build configuration list
149
+ let bundleIdentifier: string | undefined;
150
+ let infoPlistPath: string | undefined;
151
+ let entitlementsPath: string | undefined;
152
+ let deploymentTarget: string | undefined;
153
+
154
+ if (targetObj.buildConfigurationList) {
155
+ const configList = objects[targetObj.buildConfigurationList];
156
+ if (configList && configList.isa === 'XCConfigurationList') {
157
+ // Get build configurations
158
+ const buildConfigs = (configList['buildConfigurations'] as string[] | undefined) ?? [];
159
+ for (const configId of buildConfigs) {
160
+ const config = objects[configId];
161
+ if (config?.buildSettings) {
162
+ const settings = config.buildSettings;
163
+ bundleIdentifier = bundleIdentifier ?? settings['PRODUCT_BUNDLE_IDENTIFIER'];
164
+ infoPlistPath = infoPlistPath ?? settings['INFOPLIST_FILE'];
165
+ entitlementsPath = entitlementsPath ?? settings['CODE_SIGN_ENTITLEMENTS'];
166
+ deploymentTarget = deploymentTarget ?? settings['IPHONEOS_DEPLOYMENT_TARGET'];
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ // Resolve paths relative to project
173
+ if (infoPlistPath) {
174
+ infoPlistPath = path.resolve(basePath, infoPlistPath);
175
+ }
176
+ if (entitlementsPath) {
177
+ entitlementsPath = path.resolve(basePath, entitlementsPath);
178
+ }
179
+
180
+ // Extract source files
181
+ const sourceFiles = extractSourceFiles(targetObj, objects, basePath);
182
+
183
+ return {
184
+ name,
185
+ type,
186
+ bundleIdentifier,
187
+ infoPlistPath,
188
+ entitlementsPath,
189
+ deploymentTarget,
190
+ sourceFiles,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Map Xcode product type to our TargetType
196
+ */
197
+ function mapProductType(productType: string | undefined): TargetType {
198
+ if (!productType) {
199
+ return 'unknown';
200
+ }
201
+
202
+ const typeMap: Record<string, TargetType> = {
203
+ 'com.apple.product-type.application': 'application',
204
+ 'com.apple.product-type.framework': 'framework',
205
+ 'com.apple.product-type.library.static': 'staticLibrary',
206
+ 'com.apple.product-type.library.dynamic': 'dynamicLibrary',
207
+ 'com.apple.product-type.app-extension': 'appExtension',
208
+ 'com.apple.product-type.application.watchapp2': 'watchApp',
209
+ 'com.apple.product-type.watchkit2-extension': 'watchExtension',
210
+ 'com.apple.product-type.tv-app-extension': 'tvExtension',
211
+ 'com.apple.product-type.bundle.unit-test': 'unitTest',
212
+ 'com.apple.product-type.bundle.ui-testing': 'uiTest',
213
+ };
214
+
215
+ return typeMap[productType] ?? 'unknown';
216
+ }
217
+
218
+ /**
219
+ * Extract source files from a target
220
+ */
221
+ function extractSourceFiles(
222
+ targetObj: PbxObject,
223
+ objects: Record<string, PbxObject>,
224
+ basePath: string
225
+ ): string[] {
226
+ const sourceFiles: string[] = [];
227
+
228
+ // Find Sources build phase
229
+ const buildPhases = targetObj.buildPhases ?? [];
230
+ for (const phaseId of buildPhases) {
231
+ const phase = objects[phaseId];
232
+ if (phase?.isa === 'PBXSourcesBuildPhase') {
233
+ const files = phase.files ?? [];
234
+ for (const fileId of files) {
235
+ const buildFile = objects[fileId];
236
+ if (buildFile) {
237
+ const fileRef = buildFile['fileRef'] as string | undefined;
238
+ if (fileRef) {
239
+ const fileRefObj = objects[fileRef];
240
+ if (fileRefObj?.path) {
241
+ sourceFiles.push(path.resolve(basePath, fileRefObj.path));
242
+ }
243
+ }
244
+ }
245
+ }
246
+ }
247
+ }
248
+
249
+ return sourceFiles;
250
+ }
251
+
252
+ /**
253
+ * Extract build configurations
254
+ */
255
+ function extractConfigurations(pbxProject: PbxProject): string[] {
256
+ const configs = new Set<string>();
257
+
258
+ for (const obj of Object.values(pbxProject.objects)) {
259
+ if (obj.isa === 'XCBuildConfiguration' && obj.name) {
260
+ configs.add(obj.name);
261
+ }
262
+ }
263
+
264
+ return Array.from(configs);
265
+ }
@@ -0,0 +1,2 @@
1
+ export { ProgressReporter } from './reporter.js';
2
+ export type { ProgressEvent, ProgressCallback } from './types.js';
@@ -0,0 +1,65 @@
1
+ import { EventEmitter } from 'events';
2
+ import type { ProgressEvent, ProgressCallback } from './types.js';
3
+
4
+ export class ProgressReporter extends EventEmitter {
5
+ private total = 0;
6
+ private completed = 0;
7
+ private callback?: ProgressCallback | undefined;
8
+
9
+ constructor(callback?: ProgressCallback) {
10
+ super();
11
+ this.callback = callback;
12
+ }
13
+
14
+ scanStart(totalAnalyzers: number): void {
15
+ this.total = totalAnalyzers;
16
+ this.completed = 0;
17
+ const event: ProgressEvent = {
18
+ type: 'scan:start',
19
+ total: this.total,
20
+ completed: 0,
21
+ };
22
+ this.emit('scan:start', event);
23
+ this.callback?.(event);
24
+ }
25
+
26
+ analyzerStart(name: string): void {
27
+ const event: ProgressEvent = {
28
+ type: 'analyzer:start',
29
+ analyzer: name,
30
+ total: this.total,
31
+ completed: this.completed,
32
+ };
33
+ this.emit('analyzer:start', event);
34
+ this.callback?.(event);
35
+ }
36
+
37
+ analyzerComplete(name: string, duration: number): void {
38
+ this.completed++;
39
+ const event: ProgressEvent = {
40
+ type: 'analyzer:complete',
41
+ analyzer: name,
42
+ total: this.total,
43
+ completed: this.completed,
44
+ duration,
45
+ };
46
+ this.emit('analyzer:complete', event);
47
+ this.callback?.(event);
48
+ }
49
+
50
+ scanComplete(totalDuration: number): void {
51
+ const event: ProgressEvent = {
52
+ type: 'scan:complete',
53
+ total: this.total,
54
+ completed: this.completed,
55
+ duration: totalDuration,
56
+ };
57
+ this.emit('scan:complete', event);
58
+ this.callback?.(event);
59
+ }
60
+
61
+ get percentage(): number {
62
+ if (this.total === 0) return 0;
63
+ return Math.round((this.completed / this.total) * 100);
64
+ }
65
+ }
@@ -0,0 +1,9 @@
1
+ export interface ProgressEvent {
2
+ type: 'analyzer:start' | 'analyzer:complete' | 'scan:start' | 'scan:complete';
3
+ analyzer?: string;
4
+ total?: number;
5
+ completed?: number;
6
+ duration?: number;
7
+ }
8
+
9
+ export type ProgressCallback = (event: ProgressEvent) => void;