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.
- package/.claude/settings.local.json +42 -0
- package/.github/actions/ios-review/action.yml +106 -0
- package/.github/workflows/ci.yml +103 -0
- package/.github/workflows/publish.yml +57 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +175 -0
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/bitrise/step.sh +128 -0
- package/bitrise/step.yml +101 -0
- package/dist/analyzer.d.ts.map +1 -0
- package/dist/analyzers/asc-iap.d.ts.map +1 -0
- package/dist/analyzers/asc-metadata.d.ts.map +1 -0
- package/dist/analyzers/asc-screenshots.d.ts.map +1 -0
- package/dist/analyzers/asc-version.d.ts.map +1 -0
- package/dist/analyzers/code-scanner.d.ts.map +1 -0
- package/dist/analyzers/deprecated-api.d.ts.map +1 -0
- package/dist/analyzers/entitlements.d.ts.map +1 -0
- package/dist/analyzers/index.d.ts.map +1 -0
- package/dist/analyzers/info-plist.d.ts.map +1 -0
- package/dist/analyzers/privacy.d.ts.map +1 -0
- package/dist/analyzers/private-api.d.ts.map +1 -0
- package/dist/analyzers/security.d.ts.map +1 -0
- package/dist/analyzers/ui-ux.d.ts.map +1 -0
- package/dist/asc/auth.d.ts.map +1 -0
- package/dist/asc/client.d.ts.map +1 -0
- package/dist/asc/endpoints/apps.d.ts.map +1 -0
- package/dist/asc/endpoints/iap.d.ts.map +1 -0
- package/dist/asc/endpoints/screenshots.d.ts.map +1 -0
- package/dist/asc/endpoints/versions.d.ts.map +1 -0
- package/dist/asc/errors.d.ts.map +1 -0
- package/dist/asc/index.d.ts.map +1 -0
- package/dist/asc/types.d.ts.map +1 -0
- package/dist/badge/generator.d.ts.map +1 -0
- package/dist/badge/index.d.ts.map +1 -0
- package/dist/badge/types.d.ts.map +1 -0
- package/dist/cache/file-cache.d.ts.map +1 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/scan.d.ts.map +1 -0
- package/dist/cli/commands/version.d.ts.map +1 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/git/diff.d.ts.map +1 -0
- package/dist/git/index.d.ts.map +1 -0
- package/dist/git/types.d.ts.map +1 -0
- package/dist/guidelines/database.d.ts.map +1 -0
- package/dist/guidelines/index.d.ts.map +1 -0
- package/dist/guidelines/matcher.d.ts.map +1 -0
- package/dist/guidelines/types.d.ts.map +1 -0
- package/dist/history/comparator.d.ts.map +1 -0
- package/dist/history/index.d.ts.map +1 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/history/types.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +994 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/plist.d.ts.map +1 -0
- package/dist/parsers/xcodeproj.d.ts.map +1 -0
- package/dist/progress/index.d.ts.map +1 -0
- package/dist/progress/reporter.d.ts.map +1 -0
- package/dist/progress/types.d.ts.map +1 -0
- package/dist/reports/html.d.ts.map +1 -0
- package/dist/reports/index.d.ts.map +1 -0
- package/dist/reports/json.d.ts.map +1 -0
- package/dist/reports/markdown.d.ts.map +1 -0
- package/dist/reports/types.d.ts.map +1 -0
- package/dist/rules/engine.d.ts.map +1 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/loader.d.ts.map +1 -0
- package/dist/rules/types.d.ts.map +1 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ANALYZERS.md +237 -0
- package/docs/API.md +308 -0
- package/docs/BADGES.md +130 -0
- package/docs/CI_CD.md +283 -0
- package/docs/CLI.md +140 -0
- package/docs/REPORTS.md +212 -0
- package/docs/ROADMAP.md +267 -0
- package/docs/RULES.md +182 -0
- package/docs/SECURITY.md +89 -0
- package/docs/TROUBLESHOOTING.md +227 -0
- package/docs/tutorials/ASC_SETUP.md +188 -0
- package/docs/tutorials/CI_INTEGRATION.md +292 -0
- package/docs/tutorials/CUSTOM_RULES.md +291 -0
- package/docs/tutorials/GETTING_STARTED.md +226 -0
- package/docs/video-scripts/01-introduction.md +106 -0
- package/docs/video-scripts/02-cli-usage.md +120 -0
- package/docs/video-scripts/03-ci-integration.md +198 -0
- package/eslint.config.js +33 -0
- package/examples/.ios-review-rules.json +82 -0
- package/examples/bitrise-workflow.yml +129 -0
- package/examples/fastlane-lane.rb +71 -0
- package/examples/github-action.yml +147 -0
- package/fastlane/Fastfile.example +114 -0
- package/fastlane/README.md +99 -0
- package/jest.config.js +36 -0
- package/package.json +65 -0
- package/scripts/benchmark.ts +112 -0
- package/scripts/debug-parser.ts +37 -0
- package/scripts/debug-pbxproj.ts +36 -0
- package/scripts/debug-specific.ts +47 -0
- package/scripts/test-analyze.ts +67 -0
- package/scripts/xcode-cloud-review.sh +167 -0
- package/src/analyzer.ts +227 -0
- package/src/analyzers/asc-iap.ts +300 -0
- package/src/analyzers/asc-metadata.ts +326 -0
- package/src/analyzers/asc-screenshots.ts +310 -0
- package/src/analyzers/asc-version.ts +368 -0
- package/src/analyzers/code-scanner.ts +408 -0
- package/src/analyzers/deprecated-api.ts +390 -0
- package/src/analyzers/entitlements.ts +345 -0
- package/src/analyzers/index.ts +12 -0
- package/src/analyzers/info-plist.ts +409 -0
- package/src/analyzers/privacy.ts +376 -0
- package/src/analyzers/private-api.ts +377 -0
- package/src/analyzers/security.ts +327 -0
- package/src/analyzers/ui-ux.ts +509 -0
- package/src/asc/auth.ts +204 -0
- package/src/asc/client.ts +258 -0
- package/src/asc/endpoints/apps.ts +115 -0
- package/src/asc/endpoints/iap.ts +171 -0
- package/src/asc/endpoints/screenshots.ts +164 -0
- package/src/asc/endpoints/versions.ts +174 -0
- package/src/asc/errors.ts +109 -0
- package/src/asc/index.ts +108 -0
- package/src/asc/types.ts +369 -0
- package/src/badge/generator.ts +48 -0
- package/src/badge/index.ts +2 -0
- package/src/badge/types.ts +5 -0
- package/src/cache/file-cache.ts +75 -0
- package/src/cache/index.ts +2 -0
- package/src/cache/types.ts +10 -0
- package/src/cli/commands/help.ts +41 -0
- package/src/cli/commands/scan.ts +44 -0
- package/src/cli/commands/version.ts +12 -0
- package/src/cli/index.ts +92 -0
- package/src/cli/types.ts +17 -0
- package/src/git/diff.ts +21 -0
- package/src/git/index.ts +2 -0
- package/src/git/types.ts +5 -0
- package/src/guidelines/database.ts +344 -0
- package/src/guidelines/index.ts +4 -0
- package/src/guidelines/matcher.ts +84 -0
- package/src/guidelines/types.ts +28 -0
- package/src/history/comparator.ts +114 -0
- package/src/history/index.ts +3 -0
- package/src/history/store.ts +135 -0
- package/src/history/types.ts +40 -0
- package/src/index.ts +1113 -0
- package/src/parsers/index.ts +3 -0
- package/src/parsers/plist.ts +253 -0
- package/src/parsers/xcodeproj.ts +265 -0
- package/src/progress/index.ts +2 -0
- package/src/progress/reporter.ts +65 -0
- package/src/progress/types.ts +9 -0
- package/src/reports/html.ts +322 -0
- package/src/reports/index.ts +20 -0
- package/src/reports/json.ts +92 -0
- package/src/reports/markdown.ts +187 -0
- package/src/reports/types.ts +26 -0
- package/src/rules/engine.ts +121 -0
- package/src/rules/index.ts +3 -0
- package/src/rules/loader.ts +83 -0
- package/src/rules/types.ts +25 -0
- package/src/types/index.ts +247 -0
- package/tests/analyzer.test.ts +142 -0
- package/tests/analyzers/asc-iap.test.ts +228 -0
- package/tests/analyzers/asc-metadata.test.ts +210 -0
- package/tests/analyzers/asc-screenshots.test.ts +135 -0
- package/tests/analyzers/asc-version.test.ts +259 -0
- package/tests/analyzers/code-scanner.test.ts +745 -0
- package/tests/analyzers/deprecated-api.test.ts +286 -0
- package/tests/analyzers/entitlements.test.ts +411 -0
- package/tests/analyzers/info-plist.test.ts +148 -0
- package/tests/analyzers/privacy.test.ts +623 -0
- package/tests/analyzers/private-api.test.ts +255 -0
- package/tests/analyzers/security.test.ts +300 -0
- package/tests/analyzers/ui-ux.test.ts +357 -0
- package/tests/asc/auth.test.ts +189 -0
- package/tests/asc/client.test.ts +207 -0
- package/tests/asc/endpoints.test.ts +1359 -0
- package/tests/badge/generator.test.ts +73 -0
- package/tests/cache/file-cache.test.ts +124 -0
- package/tests/cli/cli-index.test.ts +510 -0
- package/tests/cli/commands.test.ts +67 -0
- package/tests/cli/scan.test.ts +152 -0
- package/tests/git/diff.test.ts +69 -0
- package/tests/guidelines/matcher.test.ts +209 -0
- package/tests/history/comparator.test.ts +272 -0
- package/tests/history/store.test.ts +200 -0
- package/tests/integration/cli.test.ts +95 -0
- package/tests/integration/e2e.test.ts +130 -0
- package/tests/parsers/plist.test.ts +240 -0
- package/tests/parsers/xcodeproj.test.ts +289 -0
- package/tests/progress/reporter.test.ts +117 -0
- package/tests/reports/html.test.ts +176 -0
- package/tests/reports/json.test.ts +235 -0
- package/tests/reports/markdown.test.ts +196 -0
- package/tests/rules/engine.test.ts +229 -0
- package/tests/rules/loader.test.ts +187 -0
- package/tests/setup.ts +15 -0
- package/tsconfig.json +27 -0
- package/tsconfig.test.json +9 -0
|
@@ -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,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;
|