@forvibe/cli 1.1.1 → 2.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/engine/data/bundle.appstore.json +10098 -0
- package/dist/index.js +3454 -370
- package/package.json +6 -3
- package/dist/review/rag-data/business.json +0 -282
- package/dist/review/rag-data/common-pitfalls.json +0 -212
- package/dist/review/rag-data/design.json +0 -212
- package/dist/review/rag-data/legal.json +0 -212
- package/dist/review/rag-data/performance.json +0 -282
- package/dist/review/rag-data/safety.json +0 -212
package/dist/index.js
CHANGED
|
@@ -6,13 +6,13 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/commands/analyze.ts
|
|
7
7
|
import { createInterface } from "readline";
|
|
8
8
|
import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
|
|
9
|
-
import { basename as basename3, extname as extname4, relative as
|
|
9
|
+
import { basename as basename3, extname as extname4, relative as relative5 } from "path";
|
|
10
10
|
import chalk from "chalk";
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
|
|
13
13
|
// src/analyzers/tech-detector.ts
|
|
14
14
|
import { existsSync as existsSync2, readdirSync as readdirSync2 } from "fs";
|
|
15
|
-
import { join as join2 } from "path";
|
|
15
|
+
import { join as join2, relative as relative2 } from "path";
|
|
16
16
|
|
|
17
17
|
// src/utils/file-scanner.ts
|
|
18
18
|
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
@@ -139,8 +139,22 @@ function readFileSafe(filePath) {
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
+
function fileExists(filePath) {
|
|
143
|
+
return existsSync(filePath);
|
|
144
|
+
}
|
|
142
145
|
|
|
143
146
|
// src/analyzers/tech-detector.ts
|
|
147
|
+
var KMP_PLUGIN_MARKER_RE = /kotlin\(\s*["']multiplatform["']\s*\)|id\(\s*["']org\.jetbrains\.kotlin\.multiplatform["']\s*\)|kotlinMultiplatform/;
|
|
148
|
+
function findKmpModuleGradleFile(dir) {
|
|
149
|
+
const candidates = findFiles(dir, ["build.gradle.kts", "build.gradle"], 2);
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
const content = readFileSafe(candidate);
|
|
152
|
+
if (content && KMP_PLUGIN_MARKER_RE.test(content)) {
|
|
153
|
+
return relative2(dir, candidate);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
144
158
|
var DETECTION_RULES = [
|
|
145
159
|
{
|
|
146
160
|
stack: "flutter",
|
|
@@ -155,6 +169,45 @@ var DETECTION_RULES = [
|
|
|
155
169
|
return files.length >= 2 ? files : [];
|
|
156
170
|
}
|
|
157
171
|
},
|
|
172
|
+
{
|
|
173
|
+
stack: "expo",
|
|
174
|
+
label: "Expo (React Native)",
|
|
175
|
+
platforms: ["ios", "android"],
|
|
176
|
+
detect: (dir) => {
|
|
177
|
+
const files = [];
|
|
178
|
+
const pkgPath = join2(dir, "package.json");
|
|
179
|
+
const pkgContent = existsSync2(pkgPath) ? readFileSafe(pkgPath) : null;
|
|
180
|
+
let hasExpoDep = false;
|
|
181
|
+
if (pkgContent) {
|
|
182
|
+
try {
|
|
183
|
+
const pkg = JSON.parse(pkgContent);
|
|
184
|
+
const allDeps = { ...pkg.dependencies || {}, ...pkg.devDependencies || {} };
|
|
185
|
+
if ("expo" in allDeps) {
|
|
186
|
+
hasExpoDep = true;
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let hasExpoBlock = false;
|
|
192
|
+
const appJsonPath = join2(dir, "app.json");
|
|
193
|
+
if (existsSync2(appJsonPath)) {
|
|
194
|
+
const appJson = readFileSafe(appJsonPath);
|
|
195
|
+
if (appJson && /"expo"\s*:\s*\{/.test(appJson)) {
|
|
196
|
+
hasExpoBlock = true;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const hasAppConfig = existsSync2(join2(dir, "app.config.js")) || existsSync2(join2(dir, "app.config.ts"));
|
|
200
|
+
const hasEas = existsSync2(join2(dir, "eas.json"));
|
|
201
|
+
if (hasExpoDep || hasExpoBlock || hasEas) {
|
|
202
|
+
if (pkgContent) files.push("package.json");
|
|
203
|
+
if (hasExpoBlock) files.push("app.json");
|
|
204
|
+
if (hasAppConfig) files.push(existsSync2(join2(dir, "app.config.ts")) ? "app.config.ts" : "app.config.js");
|
|
205
|
+
if (hasEas) files.push("eas.json");
|
|
206
|
+
if (existsSync2(join2(dir, "app"))) files.push("app/");
|
|
207
|
+
}
|
|
208
|
+
return files;
|
|
209
|
+
}
|
|
210
|
+
},
|
|
158
211
|
{
|
|
159
212
|
stack: "react-native",
|
|
160
213
|
label: "React Native",
|
|
@@ -198,7 +251,7 @@ var DETECTION_RULES = [
|
|
|
198
251
|
},
|
|
199
252
|
{
|
|
200
253
|
stack: "swift",
|
|
201
|
-
label: "Swift
|
|
254
|
+
label: "iOS Native (Swift/Objective-C)",
|
|
202
255
|
platforms: ["ios"],
|
|
203
256
|
detect: (dir) => {
|
|
204
257
|
const files = [];
|
|
@@ -215,7 +268,7 @@ var DETECTION_RULES = [
|
|
|
215
268
|
try {
|
|
216
269
|
const entries = readdirSync2(dir);
|
|
217
270
|
for (const entry of entries) {
|
|
218
|
-
if (entry.endsWith(".swift")) {
|
|
271
|
+
if (entry.endsWith(".swift") || entry.endsWith(".m") || entry.endsWith(".mm")) {
|
|
219
272
|
files.push(entry);
|
|
220
273
|
break;
|
|
221
274
|
}
|
|
@@ -225,6 +278,37 @@ var DETECTION_RULES = [
|
|
|
225
278
|
return files;
|
|
226
279
|
}
|
|
227
280
|
},
|
|
281
|
+
{
|
|
282
|
+
stack: "unity",
|
|
283
|
+
label: "Unity",
|
|
284
|
+
platforms: ["ios", "android"],
|
|
285
|
+
detect: (dir) => {
|
|
286
|
+
const manifest = join2(dir, "Packages", "manifest.json");
|
|
287
|
+
const projectSettings = join2(dir, "ProjectSettings", "ProjectSettings.asset");
|
|
288
|
+
const assets = join2(dir, "Assets");
|
|
289
|
+
if (existsSync2(manifest) && existsSync2(projectSettings) && existsSync2(assets)) {
|
|
290
|
+
return [
|
|
291
|
+
"Packages/manifest.json",
|
|
292
|
+
"ProjectSettings/ProjectSettings.asset",
|
|
293
|
+
"Assets/"
|
|
294
|
+
];
|
|
295
|
+
}
|
|
296
|
+
return [];
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
stack: "kmp",
|
|
301
|
+
label: "Kotlin Multiplatform",
|
|
302
|
+
platforms: ["ios", "android"],
|
|
303
|
+
detect: (dir) => {
|
|
304
|
+
const rootGradleFile = ["build.gradle.kts", "build.gradle", "settings.gradle.kts", "settings.gradle"].find((name) => existsSync2(join2(dir, name)));
|
|
305
|
+
if (!rootGradleFile) return [];
|
|
306
|
+
if (!existsSync2(join2(dir, "iosApp"))) return [];
|
|
307
|
+
const moduleGradleFile = findKmpModuleGradleFile(dir);
|
|
308
|
+
if (!moduleGradleFile) return [];
|
|
309
|
+
return [rootGradleFile, moduleGradleFile, "iosApp/"];
|
|
310
|
+
}
|
|
311
|
+
},
|
|
228
312
|
{
|
|
229
313
|
stack: "kotlin",
|
|
230
314
|
label: "Android (Kotlin)",
|
|
@@ -256,7 +340,9 @@ var DETECTION_RULES = [
|
|
|
256
340
|
for (const entry of entries) {
|
|
257
341
|
if (entry.endsWith(".csproj")) {
|
|
258
342
|
const content = readFileSafe(join2(dir, entry));
|
|
259
|
-
if (content
|
|
343
|
+
if (!content) continue;
|
|
344
|
+
const isMaui = /<UseMaui>\s*true\s*<\/UseMaui>/i.test(content) || /Microsoft\.Maui\.Controls/.test(content) || /Microsoft\.Maui\.Sdk/.test(content) || /-android|-ios|-maccatalyst/.test(content);
|
|
345
|
+
if (isMaui) {
|
|
260
346
|
files.push(entry);
|
|
261
347
|
}
|
|
262
348
|
}
|
|
@@ -264,6 +350,7 @@ var DETECTION_RULES = [
|
|
|
264
350
|
} catch {
|
|
265
351
|
}
|
|
266
352
|
if (existsSync2(join2(dir, "Platforms"))) files.push("Platforms/");
|
|
353
|
+
if (existsSync2(join2(dir, "Resources"))) files.push("Resources/");
|
|
267
354
|
return files.length >= 1 ? files : [];
|
|
268
355
|
}
|
|
269
356
|
}
|
|
@@ -297,17 +384,22 @@ function parseConfig(rootDir, techStack) {
|
|
|
297
384
|
switch (techStack) {
|
|
298
385
|
case "flutter":
|
|
299
386
|
return parseFlutterConfig(rootDir);
|
|
387
|
+
case "expo":
|
|
388
|
+
return parseExpoConfig(rootDir);
|
|
300
389
|
case "react-native":
|
|
301
390
|
return parseReactNativeConfig(rootDir);
|
|
302
391
|
case "swift":
|
|
303
392
|
return parseSwiftConfig(rootDir);
|
|
304
393
|
case "kotlin":
|
|
305
|
-
case "java":
|
|
306
394
|
return parseAndroidConfig(rootDir);
|
|
307
395
|
case "capacitor":
|
|
308
396
|
return parseCapacitorConfig(rootDir);
|
|
309
397
|
case "dotnet-maui":
|
|
310
398
|
return parseMauiConfig(rootDir);
|
|
399
|
+
case "unity":
|
|
400
|
+
return parseUnityConfig(rootDir);
|
|
401
|
+
case "kmp":
|
|
402
|
+
return parseKmpConfig(rootDir);
|
|
311
403
|
default:
|
|
312
404
|
return emptyConfig();
|
|
313
405
|
}
|
|
@@ -370,6 +462,82 @@ function parseFlutterConfig(rootDir) {
|
|
|
370
462
|
}
|
|
371
463
|
return config;
|
|
372
464
|
}
|
|
465
|
+
function parseExpoConfig(rootDir) {
|
|
466
|
+
const config = emptyConfig();
|
|
467
|
+
const appJsonContent = readFileSafe(join3(rootDir, "app.json"));
|
|
468
|
+
if (appJsonContent) {
|
|
469
|
+
try {
|
|
470
|
+
const appJson = JSON.parse(appJsonContent);
|
|
471
|
+
const expo = appJson.expo || appJson;
|
|
472
|
+
config.app_name = expo.name || appJson.displayName || appJson.name || null;
|
|
473
|
+
config.version = expo.version || null;
|
|
474
|
+
config.bundle_id = expo.ios?.bundleIdentifier || expo.android?.package || null;
|
|
475
|
+
config.description = expo.description || null;
|
|
476
|
+
config.min_ios_version = expo.ios?.deploymentTarget || null;
|
|
477
|
+
} catch {
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!config.app_name || !config.bundle_id || !config.version) {
|
|
481
|
+
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
482
|
+
const content = readFileSafe(join3(rootDir, name));
|
|
483
|
+
if (!content) continue;
|
|
484
|
+
if (!config.app_name) {
|
|
485
|
+
const m = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
486
|
+
if (m) config.app_name = m[1];
|
|
487
|
+
}
|
|
488
|
+
if (!config.version) {
|
|
489
|
+
const m = content.match(/version\s*:\s*['"]([^'"]+)['"]/);
|
|
490
|
+
if (m) config.version = m[1];
|
|
491
|
+
}
|
|
492
|
+
if (!config.bundle_id) {
|
|
493
|
+
const m = content.match(/bundleIdentifier\s*:\s*['"]([^'"]+)['"]/) || content.match(/package\s*:\s*['"]([^'"]+)['"]/);
|
|
494
|
+
if (m) config.bundle_id = m[1];
|
|
495
|
+
}
|
|
496
|
+
if (!config.description) {
|
|
497
|
+
const m = content.match(/description\s*:\s*['"]([^'"]+)['"]/);
|
|
498
|
+
if (m) config.description = m[1];
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const pkgContent = readFileSafe(join3(rootDir, "package.json"));
|
|
503
|
+
if (pkgContent) {
|
|
504
|
+
try {
|
|
505
|
+
const pkg = JSON.parse(pkgContent);
|
|
506
|
+
if (!config.version) config.version = pkg.version || null;
|
|
507
|
+
if (!config.description) config.description = pkg.description || null;
|
|
508
|
+
if (!config.app_name) config.app_name = pkg.name || null;
|
|
509
|
+
} catch {
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (!config.bundle_id || !config.app_name) {
|
|
513
|
+
const infoPlistPath = findFile(join3(rootDir, "ios"), "Info.plist", 4);
|
|
514
|
+
if (infoPlistPath) {
|
|
515
|
+
const plistData = parsePlist(infoPlistPath);
|
|
516
|
+
if (plistData) {
|
|
517
|
+
if (!config.bundle_id) {
|
|
518
|
+
const id = plistData.CFBundleIdentifier;
|
|
519
|
+
if (id && !isXcodeVariable(id)) config.bundle_id = id;
|
|
520
|
+
}
|
|
521
|
+
if (!config.app_name) {
|
|
522
|
+
const display = plistData.CFBundleDisplayName || plistData.CFBundleName;
|
|
523
|
+
if (display && !isXcodeVariable(display)) config.app_name = display;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (!config.bundle_id) {
|
|
529
|
+
const buildGradlePath = findFile(join3(rootDir, "android/app"), "build.gradle", 2) || findFile(join3(rootDir, "android/app"), "build.gradle.kts", 2);
|
|
530
|
+
if (buildGradlePath) {
|
|
531
|
+
const gradleConfig = parseGradle(buildGradlePath);
|
|
532
|
+
if (gradleConfig.applicationId) config.bundle_id = gradleConfig.applicationId;
|
|
533
|
+
if (!config.min_android_sdk) config.min_android_sdk = gradleConfig.minSdk || null;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (!config.app_name) {
|
|
537
|
+
config.app_name = readAndroidAppName(join3(rootDir, "android"));
|
|
538
|
+
}
|
|
539
|
+
return config;
|
|
540
|
+
}
|
|
373
541
|
function parseReactNativeConfig(rootDir) {
|
|
374
542
|
const config = emptyConfig();
|
|
375
543
|
const appJsonContent = readFileSafe(join3(rootDir, "app.json"));
|
|
@@ -380,6 +548,17 @@ function parseReactNativeConfig(rootDir) {
|
|
|
380
548
|
} catch {
|
|
381
549
|
}
|
|
382
550
|
}
|
|
551
|
+
if (!config.app_name) {
|
|
552
|
+
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
553
|
+
const content = readFileSafe(join3(rootDir, name));
|
|
554
|
+
if (!content) continue;
|
|
555
|
+
const m = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
556
|
+
if (m) {
|
|
557
|
+
config.app_name = m[1];
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
383
562
|
const pkgContent = readFileSafe(join3(rootDir, "package.json"));
|
|
384
563
|
if (pkgContent) {
|
|
385
564
|
try {
|
|
@@ -396,6 +575,12 @@ function parseReactNativeConfig(rootDir) {
|
|
|
396
575
|
if (plistData) {
|
|
397
576
|
config.bundle_id = plistData.CFBundleIdentifier || null;
|
|
398
577
|
config.min_ios_version = plistData.MinimumOSVersion || null;
|
|
578
|
+
if (!config.app_name) {
|
|
579
|
+
const display = plistData.CFBundleDisplayName || plistData.CFBundleName;
|
|
580
|
+
if (display && !isXcodeVariable(display)) {
|
|
581
|
+
config.app_name = display;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
399
584
|
}
|
|
400
585
|
}
|
|
401
586
|
if (!config.bundle_id || config.bundle_id.includes("$(")) {
|
|
@@ -420,6 +605,9 @@ function parseReactNativeConfig(rootDir) {
|
|
|
420
605
|
}
|
|
421
606
|
config.min_android_sdk = gradleConfig.minSdk || null;
|
|
422
607
|
}
|
|
608
|
+
if (!config.app_name) {
|
|
609
|
+
config.app_name = readAndroidAppName(join3(rootDir, "android"));
|
|
610
|
+
}
|
|
423
611
|
return config;
|
|
424
612
|
}
|
|
425
613
|
var EXTENSION_SUFFIXES = [
|
|
@@ -562,34 +750,26 @@ function parseSwiftConfig(rootDir) {
|
|
|
562
750
|
}
|
|
563
751
|
function parseAndroidConfig(rootDir) {
|
|
564
752
|
const config = emptyConfig();
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
753
|
+
const candidates = [];
|
|
754
|
+
const addCandidate = (p) => {
|
|
755
|
+
if (p) candidates.push(p);
|
|
756
|
+
};
|
|
757
|
+
addCandidate(findFile(join3(rootDir, "app"), "build.gradle", 2));
|
|
758
|
+
addCandidate(findFile(join3(rootDir, "app"), "build.gradle.kts", 2));
|
|
759
|
+
addCandidate(findFile(rootDir, "build.gradle", 1));
|
|
760
|
+
addCandidate(findFile(rootDir, "build.gradle.kts", 1));
|
|
761
|
+
for (const f of findFiles(rootDir, ["build.gradle", "build.gradle.kts"], 4)) {
|
|
762
|
+
if (!candidates.includes(f)) candidates.push(f);
|
|
763
|
+
}
|
|
764
|
+
for (const path2 of candidates) {
|
|
765
|
+
const g = parseGradle(path2);
|
|
766
|
+
if (!config.bundle_id && g.applicationId) config.bundle_id = g.applicationId;
|
|
767
|
+
if (!config.version && g.versionName) config.version = g.versionName;
|
|
768
|
+
if (!config.min_android_sdk && g.minSdk) config.min_android_sdk = g.minSdk;
|
|
769
|
+
if (config.bundle_id && config.version && config.min_android_sdk) break;
|
|
581
770
|
}
|
|
582
771
|
if (!config.app_name) {
|
|
583
|
-
|
|
584
|
-
if (stringsPath) {
|
|
585
|
-
const content = readFileSafe(stringsPath);
|
|
586
|
-
if (content) {
|
|
587
|
-
const nameMatch = content.match(
|
|
588
|
-
/<string name="app_name">(.*?)<\/string>/
|
|
589
|
-
);
|
|
590
|
-
if (nameMatch) config.app_name = nameMatch[1];
|
|
591
|
-
}
|
|
592
|
-
}
|
|
772
|
+
config.app_name = readAndroidAppName(rootDir);
|
|
593
773
|
}
|
|
594
774
|
return config;
|
|
595
775
|
}
|
|
@@ -657,6 +837,58 @@ function parseMauiConfig(rootDir) {
|
|
|
657
837
|
}
|
|
658
838
|
return config;
|
|
659
839
|
}
|
|
840
|
+
function parseUnityConfig(rootDir) {
|
|
841
|
+
const config = emptyConfig();
|
|
842
|
+
const content = readFileSafe(
|
|
843
|
+
join3(rootDir, "ProjectSettings", "ProjectSettings.asset")
|
|
844
|
+
);
|
|
845
|
+
if (!content) return config;
|
|
846
|
+
const productNameMatch = content.match(/^\s*productName:\s*(.+)$/m);
|
|
847
|
+
if (productNameMatch) config.app_name = productNameMatch[1].trim();
|
|
848
|
+
const versionMatch = content.match(/^\s*bundleVersion:\s*(\S+)/m);
|
|
849
|
+
if (versionMatch) config.version = versionMatch[1].trim();
|
|
850
|
+
const blockIdx = content.indexOf("applicationIdentifier:");
|
|
851
|
+
if (blockIdx >= 0) {
|
|
852
|
+
const after = content.slice(blockIdx + "applicationIdentifier:".length);
|
|
853
|
+
const nextSiblingKey = after.search(/\n {0,2}\S/);
|
|
854
|
+
const block = nextSiblingKey >= 0 ? after.slice(0, nextSiblingKey) : after;
|
|
855
|
+
const iphone = block.match(/iPhone:\s*(\S+)/);
|
|
856
|
+
const android = block.match(/Android:\s*(\S+)/);
|
|
857
|
+
const id = iphone?.[1] ?? android?.[1] ?? null;
|
|
858
|
+
if (id) config.bundle_id = id.trim();
|
|
859
|
+
}
|
|
860
|
+
return config;
|
|
861
|
+
}
|
|
862
|
+
function parseKmpConfig(rootDir) {
|
|
863
|
+
const config = emptyConfig();
|
|
864
|
+
for (const moduleName of ["androidApp", "composeApp", "app"]) {
|
|
865
|
+
const moduleDir = join3(rootDir, moduleName);
|
|
866
|
+
let gradleConfig = parseGradle(join3(moduleDir, "build.gradle.kts"));
|
|
867
|
+
if (!gradleConfig.applicationId) {
|
|
868
|
+
gradleConfig = parseGradle(join3(moduleDir, "build.gradle"));
|
|
869
|
+
}
|
|
870
|
+
if (gradleConfig.applicationId) {
|
|
871
|
+
config.bundle_id = gradleConfig.applicationId;
|
|
872
|
+
if (gradleConfig.versionName) config.version = gradleConfig.versionName;
|
|
873
|
+
if (gradleConfig.minSdk) config.min_android_sdk = gradleConfig.minSdk;
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
const infoPlistPath = findFile(join3(rootDir, "iosApp"), "Info.plist", 3);
|
|
878
|
+
if (infoPlistPath) {
|
|
879
|
+
const plistData = parsePlist(infoPlistPath);
|
|
880
|
+
if (plistData) {
|
|
881
|
+
const displayName = plistData.CFBundleDisplayName || plistData.CFBundleName;
|
|
882
|
+
if (displayName && !isXcodeVariable(displayName)) {
|
|
883
|
+
config.app_name = displayName;
|
|
884
|
+
}
|
|
885
|
+
if (!config.min_ios_version) {
|
|
886
|
+
config.min_ios_version = plistData.MinimumOSVersion || null;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return config;
|
|
891
|
+
}
|
|
660
892
|
function parsePlist(filePath) {
|
|
661
893
|
const content = readFileSafe(filePath);
|
|
662
894
|
if (!content) return null;
|
|
@@ -666,6 +898,38 @@ function parsePlist(filePath) {
|
|
|
666
898
|
return null;
|
|
667
899
|
}
|
|
668
900
|
}
|
|
901
|
+
function lookupAndroidString(searchDir, stringName) {
|
|
902
|
+
const stringsPath = findFile(searchDir, "strings.xml", 8);
|
|
903
|
+
if (!stringsPath) return null;
|
|
904
|
+
const content = readFileSafe(stringsPath);
|
|
905
|
+
if (!content) return null;
|
|
906
|
+
const escaped = stringName.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
907
|
+
const pattern = new RegExp(
|
|
908
|
+
`<string\\s+name="${escaped}"[^>]*>([\\s\\S]*?)<\\/string>`
|
|
909
|
+
);
|
|
910
|
+
const m = content.match(pattern);
|
|
911
|
+
return m ? m[1].trim() : null;
|
|
912
|
+
}
|
|
913
|
+
function readAndroidAppName(searchDir) {
|
|
914
|
+
const manifestPath = findFile(searchDir, "AndroidManifest.xml", 6);
|
|
915
|
+
if (manifestPath) {
|
|
916
|
+
const content = readFileSafe(manifestPath);
|
|
917
|
+
if (content) {
|
|
918
|
+
const labelMatch = content.match(/android:label\s*=\s*"([^"]+)"/);
|
|
919
|
+
if (labelMatch) {
|
|
920
|
+
const label = labelMatch[1];
|
|
921
|
+
const stringRef = label.match(/^@string\/(.+)$/);
|
|
922
|
+
if (stringRef) {
|
|
923
|
+
const resolved = lookupAndroidString(searchDir, stringRef[1]);
|
|
924
|
+
if (resolved) return resolved;
|
|
925
|
+
} else if (!label.startsWith("@")) {
|
|
926
|
+
return label;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
return lookupAndroidString(searchDir, "app_name");
|
|
932
|
+
}
|
|
669
933
|
function parseGradle(filePath) {
|
|
670
934
|
const content = readFileSafe(filePath);
|
|
671
935
|
if (!content) return { applicationId: null, versionName: null, minSdk: null };
|
|
@@ -687,6 +951,7 @@ function parseGradle(filePath) {
|
|
|
687
951
|
|
|
688
952
|
// src/analyzers/sdk-scanner.ts
|
|
689
953
|
import { join as join4 } from "path";
|
|
954
|
+
import { readdirSync as readdirSync4 } from "fs";
|
|
690
955
|
import YAML2 from "yaml";
|
|
691
956
|
var SDK_MAP = {
|
|
692
957
|
// Analytics
|
|
@@ -791,6 +1056,45 @@ var SDK_MAP = {
|
|
|
791
1056
|
ARKit: { category: "other", collects: [] },
|
|
792
1057
|
RealityKit: { category: "other", collects: [] },
|
|
793
1058
|
EventKit: { category: "other", collects: [] },
|
|
1059
|
+
// --- Expo SDK packages ---
|
|
1060
|
+
"expo-notifications": { category: "other", collects: ["device_info"] },
|
|
1061
|
+
"expo-tracking-transparency": { category: "ads", collects: ["device_info"], advertising: "personalized" },
|
|
1062
|
+
"expo-location": { category: "other", collects: ["location"] },
|
|
1063
|
+
"expo-contacts": { category: "other", collects: ["contacts"] },
|
|
1064
|
+
"expo-camera": { category: "other", collects: ["photos_media"] },
|
|
1065
|
+
"expo-image-picker": { category: "other", collects: ["photos_media"] },
|
|
1066
|
+
"expo-media-library": { category: "other", collects: ["photos_media"] },
|
|
1067
|
+
"expo-auth-session": { category: "auth", collects: ["name_email"] },
|
|
1068
|
+
"expo-apple-authentication": { category: "auth", collects: ["name_email"] },
|
|
1069
|
+
"expo-google-sign-in": { category: "auth", collects: ["name_email", "profile_photo"] },
|
|
1070
|
+
"expo-in-app-purchases": { category: "payment", collects: ["financial_info"] },
|
|
1071
|
+
"expo-ads-admob": { category: "ads", collects: ["device_info"], advertising: "personalized" },
|
|
1072
|
+
"expo-firebase-analytics": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1073
|
+
"expo-local-authentication": { category: "auth", collects: ["biometric_data"] },
|
|
1074
|
+
"expo-health-connect": { category: "other", collects: ["health_data"] },
|
|
1075
|
+
"expo-sensors": { category: "other", collects: ["device_info"] },
|
|
1076
|
+
"expo-device": { category: "other", collects: ["device_info"] },
|
|
1077
|
+
// --- .NET MAUI packages ---
|
|
1078
|
+
"Microsoft.AppCenter.Analytics": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1079
|
+
"Microsoft.AppCenter.Crashes": { category: "analytics", collects: ["device_info"] },
|
|
1080
|
+
"Sentry.Maui": { category: "analytics", collects: ["device_info"] },
|
|
1081
|
+
"Plugin.Firebase": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1082
|
+
"Plugin.Firebase.Analytics": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1083
|
+
"Plugin.Firebase.Auth": { category: "auth", collects: ["name_email"] },
|
|
1084
|
+
"Plugin.Firebase.CloudMessaging": { category: "other", collects: ["device_info"] },
|
|
1085
|
+
"Plugin.GoogleClient": { category: "auth", collects: ["name_email", "profile_photo"] },
|
|
1086
|
+
"Plugin.SignInWithApple": { category: "auth", collects: ["name_email"] },
|
|
1087
|
+
"Plugin.InAppBilling": { category: "payment", collects: ["financial_info"] },
|
|
1088
|
+
"CommunityToolkit.Maui": { category: "other", collects: [] },
|
|
1089
|
+
"CommunityToolkit.Maui.MediaElement": { category: "other", collects: ["photos_media"] },
|
|
1090
|
+
"Plugin.Maui.Audio": { category: "other", collects: ["photos_media"] },
|
|
1091
|
+
"Xamarin.Google.Android.Play.Review": { category: "other", collects: [] },
|
|
1092
|
+
"Plugin.LocalNotification": { category: "other", collects: [] },
|
|
1093
|
+
"Plugin.Maui.Biometric": { category: "auth", collects: ["biometric_data"] },
|
|
1094
|
+
"Plugin.Permissions": { category: "other", collects: [] },
|
|
1095
|
+
"Mapsui.Maui": { category: "other", collects: ["location"] },
|
|
1096
|
+
"Plugin.Maui.AppRating": { category: "other", collects: [] },
|
|
1097
|
+
"Plugin.Geolocator": { category: "other", collects: ["location"] },
|
|
794
1098
|
// --- Native Android frameworks (detected via `import` in Kotlin/Java source) ---
|
|
795
1099
|
"com.google.android.gms.ads": { category: "ads", collects: ["device_info"], advertising: "personalized" },
|
|
796
1100
|
"com.google.android.gms.maps": { category: "other", collects: ["location"] },
|
|
@@ -799,8 +1103,123 @@ var SDK_MAP = {
|
|
|
799
1103
|
"com.android.billingclient": { category: "payment", collects: ["financial_info"] },
|
|
800
1104
|
"androidx.health": { category: "other", collects: ["health_data"] },
|
|
801
1105
|
"androidx.biometric": { category: "auth", collects: ["biometric_data"] },
|
|
802
|
-
"androidx.camera": { category: "other", collects: ["photos_media"] }
|
|
1106
|
+
"androidx.camera": { category: "other", collects: ["photos_media"] },
|
|
1107
|
+
"androidx.compose": { category: "other", collects: [] },
|
|
1108
|
+
"androidx.navigation.compose": { category: "other", collects: [] },
|
|
1109
|
+
// --- Android artifact-name keys (unique enough that bare artifact name is safe) ---
|
|
1110
|
+
"play-services-ads": { category: "ads", collects: ["device_info"], advertising: "personalized" },
|
|
1111
|
+
"play-services-ads-lite": { category: "ads", collects: ["device_info"], advertising: "personalized" },
|
|
1112
|
+
"play-services-maps": { category: "other", collects: ["location"] },
|
|
1113
|
+
"play-services-location": { category: "other", collects: ["location"] },
|
|
1114
|
+
"play-services-auth": { category: "auth", collects: ["name_email", "profile_photo"] },
|
|
1115
|
+
"play-services-auth-api-phone": { category: "auth", collects: ["name_email"] },
|
|
1116
|
+
billing: { category: "payment", collects: ["financial_info"] },
|
|
1117
|
+
"billing-ktx": { category: "payment", collects: ["financial_info"] },
|
|
1118
|
+
biometric: { category: "auth", collects: ["biometric_data"] },
|
|
1119
|
+
"biometric-ktx": { category: "auth", collects: ["biometric_data"] },
|
|
1120
|
+
"health-connect-client": { category: "other", collects: ["health_data"] },
|
|
1121
|
+
"camera-core": { category: "other", collects: ["photos_media"] },
|
|
1122
|
+
"camera-camera2": { category: "other", collects: ["photos_media"] },
|
|
1123
|
+
"camera-lifecycle": { category: "other", collects: ["photos_media"] },
|
|
1124
|
+
"camera-view": { category: "other", collects: ["photos_media"] },
|
|
1125
|
+
"camera-extensions": { category: "other", collects: ["photos_media"] },
|
|
1126
|
+
// firebase-analytics / firebase-auth already declared above (Flutter dep names)
|
|
1127
|
+
"firebase-analytics-ktx": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1128
|
+
"firebase-auth-ktx": { category: "auth", collects: ["name_email"] },
|
|
1129
|
+
"firebase-crashlytics": { category: "analytics", collects: ["device_info"] },
|
|
1130
|
+
"firebase-crashlytics-ktx": { category: "analytics", collects: ["device_info"] },
|
|
1131
|
+
"firebase-messaging": { category: "other", collects: ["device_info"] },
|
|
1132
|
+
"firebase-messaging-ktx": { category: "other", collects: ["device_info"] },
|
|
1133
|
+
"firebase-firestore": { category: "cloud", collects: [] },
|
|
1134
|
+
"firebase-firestore-ktx": { category: "cloud", collects: [] },
|
|
1135
|
+
"firebase-storage": { category: "cloud", collects: [] },
|
|
1136
|
+
"firebase-storage-ktx": { category: "cloud", collects: [] },
|
|
1137
|
+
"firebase-database": { category: "cloud", collects: [] },
|
|
1138
|
+
"firebase-database-ktx": { category: "cloud", collects: [] },
|
|
1139
|
+
"firebase-config": { category: "cloud", collects: [] },
|
|
1140
|
+
"firebase-config-ktx": { category: "cloud", collects: [] },
|
|
1141
|
+
"firebase-functions": { category: "cloud", collects: [] },
|
|
1142
|
+
"firebase-functions-ktx": { category: "cloud", collects: [] },
|
|
1143
|
+
"hilt-android": { category: "other", collects: [] },
|
|
1144
|
+
"hilt-navigation-compose": { category: "other", collects: [] },
|
|
1145
|
+
"koin-android": { category: "other", collects: [] },
|
|
1146
|
+
"koin-androidx-compose": { category: "other", collects: [] },
|
|
1147
|
+
"room-runtime": { category: "other", collects: [] },
|
|
1148
|
+
"room-ktx": { category: "other", collects: [] },
|
|
1149
|
+
"room-compiler": { category: "other", collects: [] },
|
|
1150
|
+
retrofit: { category: "other", collects: [] },
|
|
1151
|
+
"retrofit-converter-gson": { category: "other", collects: [] },
|
|
1152
|
+
"retrofit-converter-moshi": { category: "other", collects: [] },
|
|
1153
|
+
okhttp: { category: "other", collects: [] },
|
|
1154
|
+
"okhttp-logging-interceptor": { category: "other", collects: [] },
|
|
1155
|
+
"coil-compose": { category: "other", collects: [] },
|
|
1156
|
+
coil: { category: "other", collects: [] },
|
|
1157
|
+
glide: { category: "other", collects: [] },
|
|
1158
|
+
"navigation-compose": { category: "other", collects: [] },
|
|
1159
|
+
"activity-compose": { category: "other", collects: [] },
|
|
1160
|
+
"lifecycle-viewmodel-compose": { category: "other", collects: [] },
|
|
1161
|
+
"lifecycle-runtime-compose": { category: "other", collects: [] },
|
|
1162
|
+
"work-runtime": { category: "other", collects: [] },
|
|
1163
|
+
"work-runtime-ktx": { category: "other", collects: [] },
|
|
1164
|
+
"datastore-preferences": { category: "other", collects: [] },
|
|
1165
|
+
"kotlinx-coroutines-android": { category: "other", collects: [] },
|
|
1166
|
+
// --- group:artifact keys for Compose (artifact names alone are ambiguous: "ui", "material3") ---
|
|
1167
|
+
"androidx.compose.ui:ui": { category: "other", collects: [] },
|
|
1168
|
+
"androidx.compose.ui:ui-tooling": { category: "other", collects: [] },
|
|
1169
|
+
"androidx.compose.ui:ui-tooling-preview": { category: "other", collects: [] },
|
|
1170
|
+
"androidx.compose.material3:material3": { category: "other", collects: [] },
|
|
1171
|
+
"androidx.compose.material:material": { category: "other", collects: [] },
|
|
1172
|
+
"androidx.compose.material:material-icons-extended": { category: "other", collects: [] },
|
|
1173
|
+
"androidx.compose.foundation:foundation": { category: "other", collects: [] },
|
|
1174
|
+
"androidx.compose.runtime:runtime": { category: "other", collects: [] },
|
|
1175
|
+
"androidx.compose.animation:animation": { category: "other", collects: [] },
|
|
1176
|
+
// --- Popular third-party Android SDKs (group:artifact keys) ---
|
|
1177
|
+
"com.revenuecat.purchases:purchases": { category: "payment", collects: ["financial_info"] },
|
|
1178
|
+
"com.adjust.sdk:adjust-android": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1179
|
+
"com.appsflyer:af-android-sdk": { category: "analytics", collects: ["usage_analytics", "device_info"] },
|
|
1180
|
+
"io.sentry:sentry-android": { category: "analytics", collects: ["device_info"] },
|
|
1181
|
+
"com.amplitude:android-sdk": { category: "analytics", collects: ["usage_analytics"] },
|
|
1182
|
+
"com.mixpanel.android:mixpanel-android": { category: "analytics", collects: ["usage_analytics"] },
|
|
1183
|
+
"com.posthog:posthog-android": { category: "analytics", collects: ["usage_analytics"] },
|
|
1184
|
+
"com.stripe:stripe-android": { category: "payment", collects: ["financial_info"] }
|
|
803
1185
|
};
|
|
1186
|
+
function parseVersionCatalog(content, catalog) {
|
|
1187
|
+
const idx = content.indexOf("[libraries]");
|
|
1188
|
+
if (idx < 0) return;
|
|
1189
|
+
const after = content.slice(idx + "[libraries]".length);
|
|
1190
|
+
const nextSection = after.search(/\n\s*\[[^\]]+\]/);
|
|
1191
|
+
const block = nextSection >= 0 ? after.slice(0, nextSection) : after;
|
|
1192
|
+
for (const rawLine of block.split("\n")) {
|
|
1193
|
+
const line = rawLine.replace(/#.*$/, "").trim();
|
|
1194
|
+
if (!line || line.startsWith("[")) continue;
|
|
1195
|
+
const eq = line.indexOf("=");
|
|
1196
|
+
if (eq < 0) continue;
|
|
1197
|
+
const alias = line.slice(0, eq).trim();
|
|
1198
|
+
const value = line.slice(eq + 1).trim();
|
|
1199
|
+
if (!alias) continue;
|
|
1200
|
+
const shorthand = value.match(/^"([^"]+)"$/);
|
|
1201
|
+
if (shorthand) {
|
|
1202
|
+
const parts = shorthand[1].split(":");
|
|
1203
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
1204
|
+
catalog.set(alias, `${parts[0]}:${parts[1]}`);
|
|
1205
|
+
}
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
const moduleMatch = value.match(/module\s*=\s*"([^"]+)"/);
|
|
1209
|
+
if (moduleMatch) {
|
|
1210
|
+
const parts = moduleMatch[1].split(":");
|
|
1211
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
1212
|
+
catalog.set(alias, `${parts[0]}:${parts[1]}`);
|
|
1213
|
+
}
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
const groupMatch = value.match(/group\s*=\s*"([^"]+)"/);
|
|
1217
|
+
const nameMatch = value.match(/name\s*=\s*"([^"]+)"/);
|
|
1218
|
+
if (groupMatch && nameMatch) {
|
|
1219
|
+
catalog.set(alias, `${groupMatch[1]}:${nameMatch[1]}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
804
1223
|
function scanSDKs(rootDir, techStack) {
|
|
805
1224
|
const detectedSDKs = [];
|
|
806
1225
|
const dataCollected = /* @__PURE__ */ new Set();
|
|
@@ -867,6 +1286,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
867
1286
|
}
|
|
868
1287
|
break;
|
|
869
1288
|
}
|
|
1289
|
+
case "expo":
|
|
870
1290
|
case "react-native":
|
|
871
1291
|
case "capacitor": {
|
|
872
1292
|
const pkgContent = readFileSafe(join4(rootDir, "package.json"));
|
|
@@ -880,6 +1300,30 @@ function getDependencies(rootDir, techStack) {
|
|
|
880
1300
|
}
|
|
881
1301
|
break;
|
|
882
1302
|
}
|
|
1303
|
+
case "dotnet-maui": {
|
|
1304
|
+
const csprojFiles = [];
|
|
1305
|
+
try {
|
|
1306
|
+
for (const entry of readdirSync4(rootDir)) {
|
|
1307
|
+
if (entry.endsWith(".csproj")) csprojFiles.push(join4(rootDir, entry));
|
|
1308
|
+
}
|
|
1309
|
+
} catch {
|
|
1310
|
+
}
|
|
1311
|
+
const nested = findFiles(rootDir, [".csproj"], 3);
|
|
1312
|
+
for (const f of nested) {
|
|
1313
|
+
if (!csprojFiles.includes(f)) csprojFiles.push(f);
|
|
1314
|
+
}
|
|
1315
|
+
for (const path2 of csprojFiles) {
|
|
1316
|
+
const content = readFileSafe(path2);
|
|
1317
|
+
if (!content) continue;
|
|
1318
|
+
const pkgMatches = content.matchAll(
|
|
1319
|
+
/<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/g
|
|
1320
|
+
);
|
|
1321
|
+
for (const match of pkgMatches) {
|
|
1322
|
+
if (!deps.includes(match[1])) deps.push(match[1]);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
break;
|
|
1326
|
+
}
|
|
883
1327
|
case "swift": {
|
|
884
1328
|
const podfileContent = readFileSafe(join4(rootDir, "Podfile"));
|
|
885
1329
|
if (podfileContent) {
|
|
@@ -927,29 +1371,51 @@ function getDependencies(rootDir, techStack) {
|
|
|
927
1371
|
deps.push(...nativeImports);
|
|
928
1372
|
break;
|
|
929
1373
|
}
|
|
930
|
-
case "kotlin":
|
|
931
|
-
|
|
932
|
-
const
|
|
1374
|
+
case "kotlin": {
|
|
1375
|
+
const catalog = /* @__PURE__ */ new Map();
|
|
1376
|
+
for (const p of [
|
|
1377
|
+
join4(rootDir, "gradle/libs.versions.toml"),
|
|
1378
|
+
join4(rootDir, "libs.versions.toml")
|
|
1379
|
+
]) {
|
|
1380
|
+
const content = readFileSafe(p);
|
|
1381
|
+
if (content) parseVersionCatalog(content, catalog);
|
|
1382
|
+
}
|
|
1383
|
+
const gradleFiles = /* @__PURE__ */ new Set([
|
|
933
1384
|
join4(rootDir, "app/build.gradle"),
|
|
934
1385
|
join4(rootDir, "app/build.gradle.kts"),
|
|
935
1386
|
join4(rootDir, "build.gradle"),
|
|
936
1387
|
join4(rootDir, "build.gradle.kts")
|
|
937
|
-
];
|
|
938
|
-
for (const
|
|
1388
|
+
]);
|
|
1389
|
+
for (const f of findFiles(rootDir, ["build.gradle", "build.gradle.kts"], 5)) {
|
|
1390
|
+
gradleFiles.add(f);
|
|
1391
|
+
}
|
|
1392
|
+
const addCoord = (coord) => {
|
|
1393
|
+
const parts = coord.split(":");
|
|
1394
|
+
if (parts.length < 2) return;
|
|
1395
|
+
const [group, artifact] = parts;
|
|
1396
|
+
if (!group || !artifact) return;
|
|
1397
|
+
if (!deps.includes(artifact)) deps.push(artifact);
|
|
1398
|
+
const gav = `${group}:${artifact}`;
|
|
1399
|
+
if (!deps.includes(gav)) deps.push(gav);
|
|
1400
|
+
};
|
|
1401
|
+
const coordRegex = /['"]([a-zA-Z][\w.-]*\.[a-zA-Z][\w.-]*:[a-zA-Z][\w.-]*(?::[^'"\s]+)?)['"]/g;
|
|
1402
|
+
const libsRefRegex = /\blibs\.([a-zA-Z][\w.]*)/g;
|
|
1403
|
+
for (const path2 of gradleFiles) {
|
|
939
1404
|
const content = readFileSafe(path2);
|
|
940
|
-
if (content)
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
}
|
|
949
|
-
}
|
|
1405
|
+
if (!content) continue;
|
|
1406
|
+
for (const m of content.matchAll(coordRegex)) {
|
|
1407
|
+
addCoord(m[1]);
|
|
1408
|
+
}
|
|
1409
|
+
for (const m of content.matchAll(libsRefRegex)) {
|
|
1410
|
+
const ref = m[1];
|
|
1411
|
+
const resolved = catalog.get(ref) || catalog.get(ref.replace(/\./g, "-"));
|
|
1412
|
+
if (resolved) addCoord(resolved);
|
|
950
1413
|
}
|
|
951
1414
|
}
|
|
952
|
-
const androidFiles =
|
|
1415
|
+
const androidFiles = [
|
|
1416
|
+
...findFiles(join4(rootDir, "app/src"), [".kt", ".java"], 8),
|
|
1417
|
+
...findFiles(rootDir, [".kt", ".java"], 6)
|
|
1418
|
+
];
|
|
953
1419
|
const androidNativeImports = /* @__PURE__ */ new Set();
|
|
954
1420
|
const androidFrameworkPrefixes = [
|
|
955
1421
|
"com.google.android.gms.ads",
|
|
@@ -959,16 +1425,20 @@ function getDependencies(rootDir, techStack) {
|
|
|
959
1425
|
"com.android.billingclient",
|
|
960
1426
|
"androidx.health",
|
|
961
1427
|
"androidx.biometric",
|
|
962
|
-
"androidx.camera"
|
|
1428
|
+
"androidx.camera",
|
|
1429
|
+
"androidx.compose",
|
|
1430
|
+
"androidx.navigation.compose"
|
|
963
1431
|
];
|
|
964
|
-
|
|
1432
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1433
|
+
for (const file of androidFiles) {
|
|
1434
|
+
if (seen.has(file)) continue;
|
|
1435
|
+
seen.add(file);
|
|
1436
|
+
if (seen.size > 200) break;
|
|
965
1437
|
const content = readFileSafe(file);
|
|
966
1438
|
if (!content) continue;
|
|
967
1439
|
for (const prefix of androidFrameworkPrefixes) {
|
|
968
|
-
if (content.includes(`import ${prefix}`)) {
|
|
969
|
-
|
|
970
|
-
androidNativeImports.add(prefix);
|
|
971
|
-
}
|
|
1440
|
+
if (content.includes(`import ${prefix}`) && !androidNativeImports.has(prefix)) {
|
|
1441
|
+
androidNativeImports.add(prefix);
|
|
972
1442
|
}
|
|
973
1443
|
}
|
|
974
1444
|
}
|
|
@@ -978,9 +1448,262 @@ function getDependencies(rootDir, techStack) {
|
|
|
978
1448
|
}
|
|
979
1449
|
return deps;
|
|
980
1450
|
}
|
|
1451
|
+
function dedupeSorted(values) {
|
|
1452
|
+
return Array.from(new Set(values)).sort();
|
|
1453
|
+
}
|
|
1454
|
+
function gradleCandidateFiles(baseDir) {
|
|
1455
|
+
const candidates = /* @__PURE__ */ new Set([
|
|
1456
|
+
join4(baseDir, "app/build.gradle"),
|
|
1457
|
+
join4(baseDir, "app/build.gradle.kts"),
|
|
1458
|
+
join4(baseDir, "build.gradle"),
|
|
1459
|
+
join4(baseDir, "build.gradle.kts")
|
|
1460
|
+
]);
|
|
1461
|
+
for (const f of findFiles(baseDir, ["build.gradle", "build.gradle.kts"], 5)) {
|
|
1462
|
+
candidates.add(f);
|
|
1463
|
+
}
|
|
1464
|
+
return Array.from(candidates);
|
|
1465
|
+
}
|
|
1466
|
+
function collectGradleCoordinates(androidRoot, gradleFiles) {
|
|
1467
|
+
const catalog = /* @__PURE__ */ new Map();
|
|
1468
|
+
for (const p of [
|
|
1469
|
+
join4(androidRoot, "gradle/libs.versions.toml"),
|
|
1470
|
+
join4(androidRoot, "libs.versions.toml")
|
|
1471
|
+
]) {
|
|
1472
|
+
const content = readFileSafe(p);
|
|
1473
|
+
if (content) parseVersionCatalog(content, catalog);
|
|
1474
|
+
}
|
|
1475
|
+
const coordRegex = /['"]([a-zA-Z][\w.-]*\.[a-zA-Z][\w.-]*:[a-zA-Z][\w.-]*(?::[^'"\s]+)?)['"]/g;
|
|
1476
|
+
const libsRefRegex = /\blibs\.([a-zA-Z][\w.]*)/g;
|
|
1477
|
+
const deps = [];
|
|
1478
|
+
const addCoord = (coord) => {
|
|
1479
|
+
const parts = coord.split(":");
|
|
1480
|
+
if (parts.length < 2) return;
|
|
1481
|
+
const [group, artifact] = parts;
|
|
1482
|
+
if (!group || !artifact) return;
|
|
1483
|
+
deps.push(`${group}:${artifact}`);
|
|
1484
|
+
};
|
|
1485
|
+
for (const path2 of gradleFiles) {
|
|
1486
|
+
const content = readFileSafe(path2);
|
|
1487
|
+
if (!content) continue;
|
|
1488
|
+
const filtered = content.split("\n").filter((line) => !/^\s*classpath\b/.test(line)).join("\n");
|
|
1489
|
+
for (const m of filtered.matchAll(coordRegex)) {
|
|
1490
|
+
addCoord(m[1]);
|
|
1491
|
+
}
|
|
1492
|
+
for (const m of filtered.matchAll(libsRefRegex)) {
|
|
1493
|
+
const ref = m[1];
|
|
1494
|
+
const resolved = catalog.get(ref) || catalog.get(ref.replace(/\./g, "-"));
|
|
1495
|
+
if (resolved) addCoord(resolved);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return dedupeSorted(deps);
|
|
1499
|
+
}
|
|
1500
|
+
function collectPodfilePods(podfilePath) {
|
|
1501
|
+
const content = readFileSafe(podfilePath);
|
|
1502
|
+
if (!content) return [];
|
|
1503
|
+
const deps = [];
|
|
1504
|
+
for (const m of content.matchAll(/pod\s+['"]([^'"]+)['"]/g)) {
|
|
1505
|
+
deps.push(m[1]);
|
|
1506
|
+
}
|
|
1507
|
+
return dedupeSorted(deps);
|
|
1508
|
+
}
|
|
1509
|
+
function collectSpmSlugs(rootDir) {
|
|
1510
|
+
const deps = [];
|
|
1511
|
+
const packageSwift = readFileSafe(join4(rootDir, "Package.swift"));
|
|
1512
|
+
if (packageSwift) {
|
|
1513
|
+
for (const m of packageSwift.matchAll(
|
|
1514
|
+
/\.package\([^)]*url:\s*"[^"]*\/([^/"]+?)(?:\.git)?"/g
|
|
1515
|
+
)) {
|
|
1516
|
+
deps.push(m[1].toLowerCase());
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const pbxprojPath = findFile(rootDir, "project.pbxproj", 5);
|
|
1520
|
+
if (pbxprojPath) {
|
|
1521
|
+
const pbxContent = readFileSafe(pbxprojPath);
|
|
1522
|
+
if (pbxContent) {
|
|
1523
|
+
for (const m of pbxContent.matchAll(
|
|
1524
|
+
/repositoryURL\s*=\s*"[^"]*\/([^/"]+?)(?:\.git)?"/g
|
|
1525
|
+
)) {
|
|
1526
|
+
deps.push(m[1].toLowerCase());
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
return dedupeSorted(deps);
|
|
1531
|
+
}
|
|
1532
|
+
function collectSwiftImports(rootDir) {
|
|
1533
|
+
const sourceFiles = findFiles(rootDir, [".swift", ".m", ".mm"], 6);
|
|
1534
|
+
const imports = /* @__PURE__ */ new Set();
|
|
1535
|
+
for (const file of sourceFiles.slice(0, 100)) {
|
|
1536
|
+
const content = readFileSafe(file);
|
|
1537
|
+
if (!content) continue;
|
|
1538
|
+
for (const m of content.matchAll(/^\s*import\s+(\w+)/gm)) {
|
|
1539
|
+
imports.add(m[1]);
|
|
1540
|
+
}
|
|
1541
|
+
for (const m of content.matchAll(/@import\s+(\w+)\s*;/g)) {
|
|
1542
|
+
imports.add(m[1]);
|
|
1543
|
+
}
|
|
1544
|
+
for (const m of content.matchAll(/#import\s*<([^\/>]+)(?:\/[^>]*)?>/g)) {
|
|
1545
|
+
imports.add(m[1]);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
return dedupeSorted(Array.from(imports));
|
|
1549
|
+
}
|
|
1550
|
+
function collectAndroidImports(rootDir) {
|
|
1551
|
+
const files = [
|
|
1552
|
+
...findFiles(join4(rootDir, "app/src"), [".kt", ".java"], 8),
|
|
1553
|
+
...findFiles(rootDir, [".kt", ".java"], 6)
|
|
1554
|
+
];
|
|
1555
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1556
|
+
const imports = /* @__PURE__ */ new Set();
|
|
1557
|
+
for (const file of files) {
|
|
1558
|
+
if (seen.has(file)) continue;
|
|
1559
|
+
seen.add(file);
|
|
1560
|
+
if (seen.size > 200) break;
|
|
1561
|
+
const content = readFileSafe(file);
|
|
1562
|
+
if (!content) continue;
|
|
1563
|
+
for (const m of content.matchAll(/^\s*import\s+([\w.]+)/gm)) {
|
|
1564
|
+
imports.add(m[1].replace(/\.$/, ""));
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
return dedupeSorted(Array.from(imports));
|
|
1568
|
+
}
|
|
1569
|
+
function collectPubspecDeps(pubspecPath) {
|
|
1570
|
+
const content = readFileSafe(pubspecPath);
|
|
1571
|
+
if (!content) return [];
|
|
1572
|
+
const deps = [];
|
|
1573
|
+
try {
|
|
1574
|
+
const pubspec = YAML2.parse(content);
|
|
1575
|
+
if (pubspec?.dependencies) deps.push(...Object.keys(pubspec.dependencies));
|
|
1576
|
+
if (pubspec?.dev_dependencies) deps.push(...Object.keys(pubspec.dev_dependencies));
|
|
1577
|
+
} catch {
|
|
1578
|
+
}
|
|
1579
|
+
return dedupeSorted(deps);
|
|
1580
|
+
}
|
|
1581
|
+
function collectPackageJsonDeps(pkgPath) {
|
|
1582
|
+
const content = readFileSafe(pkgPath);
|
|
1583
|
+
if (!content) return [];
|
|
1584
|
+
const deps = [];
|
|
1585
|
+
try {
|
|
1586
|
+
const pkg = JSON.parse(content);
|
|
1587
|
+
if (pkg.dependencies) deps.push(...Object.keys(pkg.dependencies));
|
|
1588
|
+
if (pkg.devDependencies) deps.push(...Object.keys(pkg.devDependencies));
|
|
1589
|
+
} catch {
|
|
1590
|
+
}
|
|
1591
|
+
return dedupeSorted(deps);
|
|
1592
|
+
}
|
|
1593
|
+
function collectCsprojPackageRefs(rootDir) {
|
|
1594
|
+
const csprojFiles = [];
|
|
1595
|
+
try {
|
|
1596
|
+
for (const entry of readdirSync4(rootDir)) {
|
|
1597
|
+
if (entry.endsWith(".csproj")) csprojFiles.push(join4(rootDir, entry));
|
|
1598
|
+
}
|
|
1599
|
+
} catch {
|
|
1600
|
+
}
|
|
1601
|
+
for (const f of findFiles(rootDir, [".csproj"], 3)) {
|
|
1602
|
+
if (!csprojFiles.includes(f)) csprojFiles.push(f);
|
|
1603
|
+
}
|
|
1604
|
+
const deps = [];
|
|
1605
|
+
for (const path2 of csprojFiles) {
|
|
1606
|
+
const content = readFileSafe(path2);
|
|
1607
|
+
if (!content) continue;
|
|
1608
|
+
for (const m of content.matchAll(/<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/g)) {
|
|
1609
|
+
deps.push(m[1]);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return dedupeSorted(deps);
|
|
1613
|
+
}
|
|
1614
|
+
function collectRawDependencies(rootDir, stack) {
|
|
1615
|
+
const deps = {
|
|
1616
|
+
npm: [],
|
|
1617
|
+
pub: [],
|
|
1618
|
+
pods: [],
|
|
1619
|
+
spm: [],
|
|
1620
|
+
gradle: [],
|
|
1621
|
+
nuget: [],
|
|
1622
|
+
upm: [],
|
|
1623
|
+
ios_imports: [],
|
|
1624
|
+
android_imports: []
|
|
1625
|
+
};
|
|
1626
|
+
switch (stack) {
|
|
1627
|
+
case "flutter": {
|
|
1628
|
+
deps.pub = collectPubspecDeps(join4(rootDir, "pubspec.yaml"));
|
|
1629
|
+
deps.pods = collectPodfilePods(join4(rootDir, "ios", "Podfile"));
|
|
1630
|
+
const androidRoot = join4(rootDir, "android");
|
|
1631
|
+
deps.gradle = collectGradleCoordinates(androidRoot, gradleCandidateFiles(androidRoot));
|
|
1632
|
+
break;
|
|
1633
|
+
}
|
|
1634
|
+
case "react-native":
|
|
1635
|
+
case "expo":
|
|
1636
|
+
case "capacitor": {
|
|
1637
|
+
deps.npm = collectPackageJsonDeps(join4(rootDir, "package.json"));
|
|
1638
|
+
deps.pods = collectPodfilePods(join4(rootDir, "ios", "Podfile"));
|
|
1639
|
+
const androidRoot = join4(rootDir, "android");
|
|
1640
|
+
deps.gradle = collectGradleCoordinates(androidRoot, gradleCandidateFiles(androidRoot));
|
|
1641
|
+
break;
|
|
1642
|
+
}
|
|
1643
|
+
case "swift": {
|
|
1644
|
+
deps.pods = collectPodfilePods(join4(rootDir, "Podfile"));
|
|
1645
|
+
deps.spm = collectSpmSlugs(rootDir);
|
|
1646
|
+
deps.ios_imports = collectSwiftImports(rootDir);
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
case "kotlin": {
|
|
1650
|
+
deps.gradle = collectGradleCoordinates(rootDir, gradleCandidateFiles(rootDir));
|
|
1651
|
+
deps.android_imports = collectAndroidImports(rootDir);
|
|
1652
|
+
break;
|
|
1653
|
+
}
|
|
1654
|
+
case "dotnet-maui": {
|
|
1655
|
+
deps.nuget = collectCsprojPackageRefs(rootDir);
|
|
1656
|
+
break;
|
|
1657
|
+
}
|
|
1658
|
+
case "unity": {
|
|
1659
|
+
const manifestContent = readFileSafe(join4(rootDir, "Packages", "manifest.json"));
|
|
1660
|
+
if (manifestContent) {
|
|
1661
|
+
try {
|
|
1662
|
+
const manifest = JSON.parse(manifestContent);
|
|
1663
|
+
if (manifest?.dependencies && typeof manifest.dependencies === "object") {
|
|
1664
|
+
deps.upm = dedupeSorted(Object.keys(manifest.dependencies));
|
|
1665
|
+
}
|
|
1666
|
+
} catch {
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
const gradleFromPlugins = [];
|
|
1670
|
+
try {
|
|
1671
|
+
for (const entry of readdirSync4(join4(rootDir, "Assets", "Plugins", "Android"))) {
|
|
1672
|
+
if (entry.endsWith(".aar")) gradleFromPlugins.push(entry.slice(0, -".aar".length));
|
|
1673
|
+
else if (entry.endsWith(".jar")) gradleFromPlugins.push(entry.slice(0, -".jar".length));
|
|
1674
|
+
}
|
|
1675
|
+
} catch {
|
|
1676
|
+
}
|
|
1677
|
+
deps.gradle = dedupeSorted(gradleFromPlugins);
|
|
1678
|
+
const podsFromPlugins = [];
|
|
1679
|
+
try {
|
|
1680
|
+
for (const entry of readdirSync4(join4(rootDir, "Assets", "Plugins", "iOS"))) {
|
|
1681
|
+
if (entry.endsWith(".xcframework")) podsFromPlugins.push(entry.slice(0, -".xcframework".length));
|
|
1682
|
+
else if (entry.endsWith(".framework")) podsFromPlugins.push(entry.slice(0, -".framework".length));
|
|
1683
|
+
}
|
|
1684
|
+
} catch {
|
|
1685
|
+
}
|
|
1686
|
+
deps.pods = dedupeSorted(podsFromPlugins);
|
|
1687
|
+
break;
|
|
1688
|
+
}
|
|
1689
|
+
case "kmp": {
|
|
1690
|
+
deps.gradle = collectGradleCoordinates(rootDir, gradleCandidateFiles(rootDir));
|
|
1691
|
+
const iosAppDir = join4(rootDir, "iosApp");
|
|
1692
|
+
deps.ios_imports = collectSwiftImports(iosAppDir);
|
|
1693
|
+
deps.pods = collectPodfilePods(join4(iosAppDir, "Podfile"));
|
|
1694
|
+
deps.spm = collectSpmSlugs(iosAppDir);
|
|
1695
|
+
deps.android_imports = collectAndroidImports(rootDir);
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
// "unknown" only.
|
|
1699
|
+
default:
|
|
1700
|
+
break;
|
|
1701
|
+
}
|
|
1702
|
+
return deps;
|
|
1703
|
+
}
|
|
981
1704
|
|
|
982
1705
|
// src/analyzers/branding.ts
|
|
983
|
-
import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync as
|
|
1706
|
+
import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
|
|
984
1707
|
import { join as join5, extname } from "path";
|
|
985
1708
|
function extractBranding(rootDir, techStack) {
|
|
986
1709
|
const result = {
|
|
@@ -1003,20 +1726,129 @@ function extractColors(rootDir, techStack) {
|
|
|
1003
1726
|
switch (techStack) {
|
|
1004
1727
|
case "flutter":
|
|
1005
1728
|
return extractFlutterColors(rootDir);
|
|
1729
|
+
case "expo":
|
|
1730
|
+
return extractExpoColors(rootDir);
|
|
1006
1731
|
case "react-native":
|
|
1007
1732
|
case "capacitor":
|
|
1008
1733
|
return extractJSColors(rootDir);
|
|
1009
1734
|
case "swift":
|
|
1010
1735
|
return extractSwiftColors(rootDir);
|
|
1011
1736
|
case "kotlin":
|
|
1012
|
-
case "java":
|
|
1013
1737
|
return extractAndroidColors(rootDir);
|
|
1738
|
+
case "dotnet-maui":
|
|
1739
|
+
return extractMauiColors(rootDir);
|
|
1014
1740
|
default:
|
|
1015
1741
|
return { primary: null, secondary: null, allColors: [] };
|
|
1016
1742
|
}
|
|
1017
1743
|
}
|
|
1018
|
-
function
|
|
1019
|
-
const
|
|
1744
|
+
function extractExpoColors(rootDir) {
|
|
1745
|
+
const hexColors = [];
|
|
1746
|
+
let primary = null;
|
|
1747
|
+
let secondary = null;
|
|
1748
|
+
const HEX = /^#[0-9a-fA-F]{6}$/;
|
|
1749
|
+
const normalize = (raw) => {
|
|
1750
|
+
if (!raw) return null;
|
|
1751
|
+
const v = raw.trim();
|
|
1752
|
+
if (HEX.test(v)) return v.toLowerCase();
|
|
1753
|
+
if (/^#[0-9a-fA-F]{8}$/.test(v)) return `#${v.slice(3).toLowerCase()}`;
|
|
1754
|
+
return null;
|
|
1755
|
+
};
|
|
1756
|
+
const appJsonContent = readFileSafe(join5(rootDir, "app.json"));
|
|
1757
|
+
if (appJsonContent) {
|
|
1758
|
+
try {
|
|
1759
|
+
const data = JSON.parse(appJsonContent);
|
|
1760
|
+
const expo = data.expo || data;
|
|
1761
|
+
const candidates = [
|
|
1762
|
+
expo.primaryColor,
|
|
1763
|
+
expo.splash?.backgroundColor,
|
|
1764
|
+
expo.android?.adaptiveIcon?.backgroundColor,
|
|
1765
|
+
expo.android?.splash?.backgroundColor,
|
|
1766
|
+
expo.ios?.splash?.backgroundColor
|
|
1767
|
+
];
|
|
1768
|
+
for (const c of candidates) {
|
|
1769
|
+
const n = normalize(c || "");
|
|
1770
|
+
if (n) hexColors.push(n);
|
|
1771
|
+
}
|
|
1772
|
+
primary = normalize(expo.primaryColor || "") || null;
|
|
1773
|
+
} catch {
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
const jsResult = extractJSColors(rootDir);
|
|
1777
|
+
if (!primary) primary = jsResult.primary;
|
|
1778
|
+
if (!secondary) secondary = jsResult.secondary;
|
|
1779
|
+
hexColors.push(...jsResult.allColors);
|
|
1780
|
+
return {
|
|
1781
|
+
primary: primary || (hexColors[0] ?? null),
|
|
1782
|
+
secondary: secondary || (hexColors[1] ?? null),
|
|
1783
|
+
allColors: hexColors
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
function extractMauiColors(rootDir) {
|
|
1787
|
+
const hexColors = [];
|
|
1788
|
+
const xamlPaths = [
|
|
1789
|
+
join5(rootDir, "Resources/Styles/Colors.xaml"),
|
|
1790
|
+
join5(rootDir, "Resources/Styles/Styles.xaml")
|
|
1791
|
+
];
|
|
1792
|
+
const allXaml = findFiles(rootDir, [".xaml"], 5);
|
|
1793
|
+
for (const p of allXaml) {
|
|
1794
|
+
if (/Colors\.xaml$/i.test(p) && !xamlPaths.includes(p)) xamlPaths.push(p);
|
|
1795
|
+
}
|
|
1796
|
+
for (const path2 of xamlPaths) {
|
|
1797
|
+
const content = readFileSafe(path2);
|
|
1798
|
+
if (!content) continue;
|
|
1799
|
+
const colorMatches = content.matchAll(
|
|
1800
|
+
/<Color\s+x:Key="([^"]+)"\s*>\s*#([0-9a-fA-F]{6,8})\s*<\/Color>/g
|
|
1801
|
+
);
|
|
1802
|
+
for (const match of colorMatches) {
|
|
1803
|
+
const name = match[1].toLowerCase();
|
|
1804
|
+
let hex = match[2];
|
|
1805
|
+
if (hex.length === 8) hex = hex.substring(2);
|
|
1806
|
+
const color = `#${hex.toLowerCase()}`;
|
|
1807
|
+
if (name.includes("primary") && !name.includes("dark") && !name.includes("light")) {
|
|
1808
|
+
hexColors.unshift(color);
|
|
1809
|
+
} else {
|
|
1810
|
+
hexColors.push(color);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
const brushMatches = content.matchAll(
|
|
1814
|
+
/<SolidColorBrush\s+[^>]*Color\s*=\s*"#([0-9a-fA-F]{6,8})"/g
|
|
1815
|
+
);
|
|
1816
|
+
for (const match of brushMatches) {
|
|
1817
|
+
let hex = match[1];
|
|
1818
|
+
if (hex.length === 8) hex = hex.substring(2);
|
|
1819
|
+
hexColors.push(`#${hex.toLowerCase()}`);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
const csFiles = findFiles(rootDir, [".cs"], 5);
|
|
1823
|
+
for (const file of csFiles.slice(0, 50)) {
|
|
1824
|
+
const content = readFileSafe(file);
|
|
1825
|
+
if (!content) continue;
|
|
1826
|
+
const argbMatches = content.matchAll(
|
|
1827
|
+
/Color\.FromArgb\s*\(\s*"#?([0-9a-fA-F]{6,8})"/g
|
|
1828
|
+
);
|
|
1829
|
+
for (const match of argbMatches) {
|
|
1830
|
+
let hex = match[1];
|
|
1831
|
+
if (hex.length === 8) hex = hex.substring(2);
|
|
1832
|
+
hexColors.push(`#${hex.toLowerCase()}`);
|
|
1833
|
+
}
|
|
1834
|
+
const rgbMatches = content.matchAll(
|
|
1835
|
+
/Color\.FromRgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/g
|
|
1836
|
+
);
|
|
1837
|
+
for (const match of rgbMatches) {
|
|
1838
|
+
const r = parseInt(match[1]).toString(16).padStart(2, "0");
|
|
1839
|
+
const g = parseInt(match[2]).toString(16).padStart(2, "0");
|
|
1840
|
+
const b = parseInt(match[3]).toString(16).padStart(2, "0");
|
|
1841
|
+
hexColors.push(`#${r}${g}${b}`);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
return {
|
|
1845
|
+
primary: hexColors[0] ?? null,
|
|
1846
|
+
secondary: hexColors[1] ?? null,
|
|
1847
|
+
allColors: hexColors
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
function isIrrelevantColor(hex) {
|
|
1851
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
1020
1852
|
const g = parseInt(hex.slice(3, 5), 16);
|
|
1021
1853
|
const b = parseInt(hex.slice(5, 7), 16);
|
|
1022
1854
|
const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
|
|
@@ -1132,7 +1964,7 @@ function extractSwiftColors(rootDir) {
|
|
|
1132
1964
|
}
|
|
1133
1965
|
for (const catalog of assetsCatalogs) {
|
|
1134
1966
|
try {
|
|
1135
|
-
const entries =
|
|
1967
|
+
const entries = readdirSync5(catalog);
|
|
1136
1968
|
for (const entry of entries) {
|
|
1137
1969
|
if (entry.endsWith(".colorset") && entry !== "AccentColor.colorset") {
|
|
1138
1970
|
const color = parseColorSet(join5(catalog, entry));
|
|
@@ -1181,51 +2013,103 @@ function extractSwiftColors(rootDir) {
|
|
|
1181
2013
|
}
|
|
1182
2014
|
function extractAndroidColors(rootDir) {
|
|
1183
2015
|
const hexColors = [];
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
2016
|
+
let primary = null;
|
|
2017
|
+
let secondary = null;
|
|
2018
|
+
const normalize = (hexDigits) => {
|
|
2019
|
+
const h = hexDigits.length === 8 ? hexDigits.slice(2) : hexDigits;
|
|
2020
|
+
return `#${h.toLowerCase()}`;
|
|
2021
|
+
};
|
|
2022
|
+
const colorMap = /* @__PURE__ */ new Map();
|
|
2023
|
+
const colorsFiles = findAllFiles(rootDir, "colors.xml", 8);
|
|
2024
|
+
for (const path2 of colorsFiles) {
|
|
2025
|
+
const content = readFileSafe(path2);
|
|
2026
|
+
if (!content) continue;
|
|
2027
|
+
const matches = content.matchAll(
|
|
2028
|
+
/<color\s+name="([^"]+)"[^>]*>\s*#([0-9a-fA-F]{6,8})\s*<\/color>/g
|
|
2029
|
+
);
|
|
2030
|
+
for (const m of matches) {
|
|
2031
|
+
const hex = normalize(m[2]);
|
|
2032
|
+
colorMap.set(m[1], hex);
|
|
2033
|
+
hexColors.push(hex);
|
|
2034
|
+
const name = m[1].toLowerCase();
|
|
2035
|
+
if (name.includes("primary") && !name.includes("variant") && !name.includes("dark") && !name.includes("container") && !primary) {
|
|
2036
|
+
primary = hex;
|
|
2037
|
+
} else if ((name.includes("secondary") || name.includes("accent")) && !secondary) {
|
|
2038
|
+
secondary = hex;
|
|
1205
2039
|
}
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
const themeFiles = [
|
|
2043
|
+
...findAllFiles(rootDir, "themes.xml", 8),
|
|
2044
|
+
...findAllFiles(rootDir, "styles.xml", 8)
|
|
2045
|
+
];
|
|
2046
|
+
for (const path2 of themeFiles) {
|
|
2047
|
+
const content = readFileSafe(path2);
|
|
2048
|
+
if (!content) continue;
|
|
2049
|
+
const itemMatches = content.matchAll(
|
|
2050
|
+
/<item\s+name="(?:android:)?([A-Za-z]+)"[^>]*>\s*(?:@color\/([A-Za-z0-9_]+)|#([0-9a-fA-F]{6,8}))\s*<\/item>/g
|
|
2051
|
+
);
|
|
2052
|
+
for (const m of itemMatches) {
|
|
2053
|
+
const itemName = m[1].toLowerCase();
|
|
2054
|
+
if (!itemName.startsWith("color")) continue;
|
|
2055
|
+
let hex = null;
|
|
2056
|
+
if (m[2]) hex = colorMap.get(m[2]) || null;
|
|
2057
|
+
else if (m[3]) hex = normalize(m[3]);
|
|
2058
|
+
if (!hex) continue;
|
|
2059
|
+
if (itemName === "colorprimary" || itemName === "colorprimarydark") {
|
|
2060
|
+
if (!primary) primary = hex;
|
|
2061
|
+
} else if (itemName === "colorsecondary" || itemName === "colortertiary" || itemName === "coloraccent") {
|
|
2062
|
+
if (!secondary) secondary = hex;
|
|
1212
2063
|
}
|
|
2064
|
+
hexColors.push(hex);
|
|
1213
2065
|
}
|
|
1214
2066
|
}
|
|
1215
2067
|
const ktFiles = findFiles(rootDir, [".kt"], 6);
|
|
1216
|
-
for (const file of ktFiles.slice(0,
|
|
2068
|
+
for (const file of ktFiles.slice(0, 80)) {
|
|
1217
2069
|
const fileName = file.toLowerCase();
|
|
1218
2070
|
if (!fileName.includes("color") && !fileName.includes("theme")) continue;
|
|
1219
2071
|
const content = readFileSafe(file);
|
|
1220
2072
|
if (!content) continue;
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
|
|
2073
|
+
const valMap = /* @__PURE__ */ new Map();
|
|
2074
|
+
const valMatches = content.matchAll(
|
|
2075
|
+
/val\s+(\w+)\s*(?::\s*Color\s*)?=\s*Color\s*\(\s*0[xX]([0-9a-fA-F]{8})\s*\)/g
|
|
2076
|
+
);
|
|
2077
|
+
for (const m of valMatches) {
|
|
2078
|
+
const hex = normalize(m[2]);
|
|
2079
|
+
valMap.set(m[1], hex);
|
|
2080
|
+
hexColors.push(hex);
|
|
2081
|
+
}
|
|
2082
|
+
const literalMatches = content.matchAll(/Color\s*\(\s*0[xX]([0-9a-fA-F]{8})\s*\)/g);
|
|
2083
|
+
for (const m of literalMatches) {
|
|
2084
|
+
hexColors.push(normalize(m[1]));
|
|
2085
|
+
}
|
|
2086
|
+
const schemeMatch = content.match(
|
|
2087
|
+
/(?:light|dark)ColorScheme\s*\(\s*([\s\S]*?)\n\s*\)/
|
|
2088
|
+
);
|
|
2089
|
+
if (schemeMatch) {
|
|
2090
|
+
const body = schemeMatch[1];
|
|
2091
|
+
const resolve = (key) => {
|
|
2092
|
+
const re = new RegExp(
|
|
2093
|
+
`\\b${key}\\s*=\\s*(?:Color\\s*\\(\\s*0[xX]([0-9a-fA-F]{8})\\s*\\)|(\\w+))`
|
|
2094
|
+
);
|
|
2095
|
+
const m = body.match(re);
|
|
2096
|
+
if (!m) return null;
|
|
2097
|
+
if (m[1]) return normalize(m[1]);
|
|
2098
|
+
return valMap.get(m[2]) || null;
|
|
2099
|
+
};
|
|
2100
|
+
if (!primary) {
|
|
2101
|
+
const p = resolve("primary");
|
|
2102
|
+
if (p) primary = p;
|
|
2103
|
+
}
|
|
2104
|
+
if (!secondary) {
|
|
2105
|
+
const s = resolve("secondary") || resolve("tertiary");
|
|
2106
|
+
if (s) secondary = s;
|
|
2107
|
+
}
|
|
1224
2108
|
}
|
|
1225
2109
|
}
|
|
1226
2110
|
return {
|
|
1227
|
-
primary:
|
|
1228
|
-
secondary:
|
|
2111
|
+
primary: primary || hexColors[0] || null,
|
|
2112
|
+
secondary: secondary || hexColors[1] || null,
|
|
1229
2113
|
allColors: hexColors
|
|
1230
2114
|
};
|
|
1231
2115
|
}
|
|
@@ -1234,7 +2118,7 @@ function findAssetsCatalogs(rootDir) {
|
|
|
1234
2118
|
function search(dir, depth) {
|
|
1235
2119
|
if (depth > 5) return;
|
|
1236
2120
|
try {
|
|
1237
|
-
const entries =
|
|
2121
|
+
const entries = readdirSync5(dir);
|
|
1238
2122
|
for (const entry of entries) {
|
|
1239
2123
|
if (entry.startsWith(".") || entry === "Pods" || entry === "DerivedData") continue;
|
|
1240
2124
|
const fullPath = join5(dir, entry);
|
|
@@ -1300,6 +2184,18 @@ function findAppIcon(rootDir, techStack) {
|
|
|
1300
2184
|
join5(rootDir, "android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png")
|
|
1301
2185
|
);
|
|
1302
2186
|
break;
|
|
2187
|
+
case "expo":
|
|
2188
|
+
iconPaths.push(
|
|
2189
|
+
...resolveExpoIconPaths(rootDir),
|
|
2190
|
+
// Common Expo defaults
|
|
2191
|
+
join5(rootDir, "assets/icon.png"),
|
|
2192
|
+
join5(rootDir, "assets/adaptive-icon.png"),
|
|
2193
|
+
join5(rootDir, "assets/images/icon.png"),
|
|
2194
|
+
join5(rootDir, "assets/images/adaptive-icon.png"),
|
|
2195
|
+
...findIconsInAppIconSet(join5(rootDir, "ios")),
|
|
2196
|
+
join5(rootDir, "android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png")
|
|
2197
|
+
);
|
|
2198
|
+
break;
|
|
1303
2199
|
case "react-native":
|
|
1304
2200
|
case "capacitor":
|
|
1305
2201
|
iconPaths.push(
|
|
@@ -1315,13 +2211,47 @@ function findAppIcon(rootDir, techStack) {
|
|
|
1315
2211
|
...findIconsInAppIconSet(rootDir)
|
|
1316
2212
|
);
|
|
1317
2213
|
break;
|
|
1318
|
-
case "kotlin":
|
|
1319
|
-
|
|
2214
|
+
case "kotlin": {
|
|
2215
|
+
const modules = ["app", "mobile", "androidApp", "presentation", ""];
|
|
2216
|
+
const densities = ["xxxhdpi", "xxhdpi", "xhdpi"];
|
|
2217
|
+
for (const mod of modules) {
|
|
2218
|
+
const prefix = mod ? `${mod}/` : "";
|
|
2219
|
+
iconPaths.push(
|
|
2220
|
+
join5(rootDir, `${prefix}src/main/ic_launcher-playstore.png`),
|
|
2221
|
+
join5(rootDir, `${prefix}ic_launcher-playstore.png`)
|
|
2222
|
+
);
|
|
2223
|
+
}
|
|
2224
|
+
for (const mod of modules) {
|
|
2225
|
+
const prefix = mod ? `${mod}/` : "";
|
|
2226
|
+
for (const d of densities) {
|
|
2227
|
+
iconPaths.push(
|
|
2228
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher_foreground.png`),
|
|
2229
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher_foreground.webp`)
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
for (const mod of modules) {
|
|
2234
|
+
const prefix = mod ? `${mod}/` : "";
|
|
2235
|
+
for (const d of densities) {
|
|
2236
|
+
iconPaths.push(
|
|
2237
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher.png`),
|
|
2238
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher.webp`),
|
|
2239
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher_round.png`),
|
|
2240
|
+
join5(rootDir, `${prefix}src/main/res/mipmap-${d}/ic_launcher_round.webp`)
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
break;
|
|
2245
|
+
}
|
|
2246
|
+
case "dotnet-maui":
|
|
1320
2247
|
iconPaths.push(
|
|
1321
|
-
join5(rootDir, "
|
|
1322
|
-
join5(rootDir, "
|
|
1323
|
-
join5(rootDir, "
|
|
1324
|
-
join5(rootDir, "
|
|
2248
|
+
join5(rootDir, "Resources/AppIcon/appicon.png"),
|
|
2249
|
+
join5(rootDir, "Resources/AppIcon/appiconfg.png"),
|
|
2250
|
+
join5(rootDir, "Resources/Images/icon.png"),
|
|
2251
|
+
join5(rootDir, "Resources/Images/appicon.png"),
|
|
2252
|
+
// SVG fallback (MAUI default is SVG appicon.svg)
|
|
2253
|
+
join5(rootDir, "Resources/AppIcon/appicon.svg"),
|
|
2254
|
+
join5(rootDir, "Resources/AppIcon/appiconfg.svg")
|
|
1325
2255
|
);
|
|
1326
2256
|
break;
|
|
1327
2257
|
}
|
|
@@ -1333,7 +2263,7 @@ function findAppIcon(rootDir, techStack) {
|
|
|
1333
2263
|
if (stat.size < 100) continue;
|
|
1334
2264
|
const buffer = readFileSync2(iconPath);
|
|
1335
2265
|
const ext = extname(iconPath).toLowerCase();
|
|
1336
|
-
const mime = ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : "image/png";
|
|
2266
|
+
const mime = ext === ".png" ? "image/png" : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg" : ext === ".svg" ? "image/svg+xml" : ext === ".webp" ? "image/webp" : "image/png";
|
|
1337
2267
|
const base64 = `data:${mime};base64,${buffer.toString("base64")}`;
|
|
1338
2268
|
return { base64, path: iconPath };
|
|
1339
2269
|
} catch {
|
|
@@ -1343,12 +2273,33 @@ function findAppIcon(rootDir, techStack) {
|
|
|
1343
2273
|
}
|
|
1344
2274
|
return { base64: null, path: null };
|
|
1345
2275
|
}
|
|
2276
|
+
function resolveExpoIconPaths(rootDir) {
|
|
2277
|
+
const paths = [];
|
|
2278
|
+
const appJsonContent = readFileSafe(join5(rootDir, "app.json"));
|
|
2279
|
+
if (!appJsonContent) return paths;
|
|
2280
|
+
try {
|
|
2281
|
+
const data = JSON.parse(appJsonContent);
|
|
2282
|
+
const expo = data.expo || data;
|
|
2283
|
+
const candidates = [
|
|
2284
|
+
expo.icon,
|
|
2285
|
+
expo.android?.icon,
|
|
2286
|
+
expo.android?.adaptiveIcon?.foregroundImage,
|
|
2287
|
+
expo.ios?.icon
|
|
2288
|
+
].filter((v) => typeof v === "string");
|
|
2289
|
+
for (const rel of candidates) {
|
|
2290
|
+
const cleaned = rel.replace(/^\.?\//, "");
|
|
2291
|
+
paths.push(join5(rootDir, cleaned));
|
|
2292
|
+
}
|
|
2293
|
+
} catch {
|
|
2294
|
+
}
|
|
2295
|
+
return paths;
|
|
2296
|
+
}
|
|
1346
2297
|
function findIconsInAppIconSet(searchDir) {
|
|
1347
2298
|
const icons = [];
|
|
1348
2299
|
function searchRecursive(dir, depth) {
|
|
1349
2300
|
if (depth > 6) return;
|
|
1350
2301
|
try {
|
|
1351
|
-
const entries =
|
|
2302
|
+
const entries = readdirSync5(dir);
|
|
1352
2303
|
for (const entry of entries) {
|
|
1353
2304
|
const fullPath = join5(dir, entry);
|
|
1354
2305
|
if (entry === "AppIcon.appiconset") {
|
|
@@ -1372,7 +2323,7 @@ function findIconsInAppIconSet(searchDir) {
|
|
|
1372
2323
|
}
|
|
1373
2324
|
}
|
|
1374
2325
|
try {
|
|
1375
|
-
const iconEntries =
|
|
2326
|
+
const iconEntries = readdirSync5(fullPath);
|
|
1376
2327
|
for (const ie of iconEntries) {
|
|
1377
2328
|
if (ie.endsWith(".png")) {
|
|
1378
2329
|
icons.push(join5(fullPath, ie));
|
|
@@ -1400,7 +2351,7 @@ function findIconsInAppIconSet(searchDir) {
|
|
|
1400
2351
|
}
|
|
1401
2352
|
|
|
1402
2353
|
// src/analyzers/font-detector.ts
|
|
1403
|
-
import { existsSync as existsSync4, readdirSync as
|
|
2354
|
+
import { existsSync as existsSync4, readdirSync as readdirSync6, statSync as statSync3 } from "fs";
|
|
1404
2355
|
import { join as join6, extname as extname2, basename } from "path";
|
|
1405
2356
|
import yaml from "yaml";
|
|
1406
2357
|
import plist2 from "plist";
|
|
@@ -1452,7 +2403,7 @@ function scanForFontFiles(dir, maxDepth = 3) {
|
|
|
1452
2403
|
function walk(d, depth) {
|
|
1453
2404
|
if (depth > maxDepth) return;
|
|
1454
2405
|
try {
|
|
1455
|
-
const entries =
|
|
2406
|
+
const entries = readdirSync6(d);
|
|
1456
2407
|
for (const entry of entries) {
|
|
1457
2408
|
if (entry.startsWith(".")) continue;
|
|
1458
2409
|
const fullPath = join6(d, entry);
|
|
@@ -1590,7 +2541,7 @@ function findPlistFiles(rootDir) {
|
|
|
1590
2541
|
function search(dir, depth) {
|
|
1591
2542
|
if (depth > 4) return;
|
|
1592
2543
|
try {
|
|
1593
|
-
const entries =
|
|
2544
|
+
const entries = readdirSync6(dir);
|
|
1594
2545
|
for (const entry of entries) {
|
|
1595
2546
|
if (entry.startsWith(".") || entry === "Pods" || entry === "DerivedData" || entry === "build") continue;
|
|
1596
2547
|
const fullPath = join6(dir, entry);
|
|
@@ -1646,12 +2597,111 @@ function detectAndroidFonts(rootDir) {
|
|
|
1646
2597
|
}
|
|
1647
2598
|
return fonts;
|
|
1648
2599
|
}
|
|
2600
|
+
function detectExpoFonts(rootDir) {
|
|
2601
|
+
const fonts = [];
|
|
2602
|
+
const fontDirs = [
|
|
2603
|
+
join6(rootDir, "assets/fonts"),
|
|
2604
|
+
join6(rootDir, "src/assets/fonts"),
|
|
2605
|
+
join6(rootDir, "assets")
|
|
2606
|
+
];
|
|
2607
|
+
for (const dir of fontDirs) {
|
|
2608
|
+
const fontFiles = scanForFontFiles(dir, 2);
|
|
2609
|
+
for (const f of fontFiles) {
|
|
2610
|
+
fonts.push(fontNameFromFile(f));
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
const jsFiles = findFiles(rootDir, [".ts", ".tsx", ".js", ".jsx"], 6);
|
|
2614
|
+
for (const file of jsFiles.slice(0, 100)) {
|
|
2615
|
+
const content = readFileSafe(file);
|
|
2616
|
+
if (!content) continue;
|
|
2617
|
+
const useFontsMatch = content.match(/useFonts\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
2618
|
+
if (useFontsMatch) {
|
|
2619
|
+
const block = useFontsMatch[1];
|
|
2620
|
+
const keyMatches = block.matchAll(/['"`]([^'"`]+)['"`]\s*:/g);
|
|
2621
|
+
for (const m of keyMatches) {
|
|
2622
|
+
fonts.push(m[1]);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
const loadAsyncMatch = content.match(/Font\.loadAsync\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
2626
|
+
if (loadAsyncMatch) {
|
|
2627
|
+
const block = loadAsyncMatch[1];
|
|
2628
|
+
const keyMatches = block.matchAll(/['"`]([^'"`]+)['"`]\s*:/g);
|
|
2629
|
+
for (const m of keyMatches) {
|
|
2630
|
+
fonts.push(m[1]);
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
const familyMatches = content.matchAll(/fontFamily\s*:\s*['"]([^'"]+)['"]/g);
|
|
2634
|
+
for (const m of familyMatches) {
|
|
2635
|
+
fonts.push(m[1]);
|
|
2636
|
+
}
|
|
2637
|
+
}
|
|
2638
|
+
const appJsonContent = readFileSafe(join6(rootDir, "app.json"));
|
|
2639
|
+
if (appJsonContent) {
|
|
2640
|
+
try {
|
|
2641
|
+
const data = JSON.parse(appJsonContent);
|
|
2642
|
+
const expoFonts = data.expo?.fonts || data.fonts;
|
|
2643
|
+
if (Array.isArray(expoFonts)) {
|
|
2644
|
+
for (const entry of expoFonts) {
|
|
2645
|
+
if (typeof entry === "string") {
|
|
2646
|
+
fonts.push(fontNameFromFile(entry));
|
|
2647
|
+
} else if (entry?.name) {
|
|
2648
|
+
fonts.push(entry.name);
|
|
2649
|
+
} else if (entry?.path) {
|
|
2650
|
+
fonts.push(fontNameFromFile(entry.path));
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
}
|
|
2654
|
+
} catch {
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
return fonts;
|
|
2658
|
+
}
|
|
2659
|
+
function detectMauiFonts(rootDir) {
|
|
2660
|
+
const fonts = [];
|
|
2661
|
+
const fontDirs = [
|
|
2662
|
+
join6(rootDir, "Resources/Fonts"),
|
|
2663
|
+
join6(rootDir, "Resources/Font")
|
|
2664
|
+
];
|
|
2665
|
+
for (const dir of fontDirs) {
|
|
2666
|
+
const fontFiles = scanForFontFiles(dir, 2);
|
|
2667
|
+
for (const f of fontFiles) {
|
|
2668
|
+
fonts.push(fontNameFromFile(f));
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
const csFiles = findFiles(rootDir, [".cs"], 4);
|
|
2672
|
+
for (const file of csFiles.slice(0, 30)) {
|
|
2673
|
+
const content = readFileSafe(file);
|
|
2674
|
+
if (!content) continue;
|
|
2675
|
+
if (!content.includes("AddFont")) continue;
|
|
2676
|
+
const matches = content.matchAll(
|
|
2677
|
+
/\.AddFont\s*\(\s*"([^"]+)"\s*,\s*"([^"]+)"\s*\)/g
|
|
2678
|
+
);
|
|
2679
|
+
for (const match of matches) {
|
|
2680
|
+
fonts.push(match[2]);
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
const xamlFiles = findFiles(rootDir, [".xaml"], 5);
|
|
2684
|
+
for (const file of xamlFiles.slice(0, 50)) {
|
|
2685
|
+
const content = readFileSafe(file);
|
|
2686
|
+
if (!content) continue;
|
|
2687
|
+
const matches = content.matchAll(/FontFamily\s*=\s*"([^"]+)"/g);
|
|
2688
|
+
for (const match of matches) {
|
|
2689
|
+
const v = match[1];
|
|
2690
|
+
if (v.startsWith("{") || v.includes(",")) continue;
|
|
2691
|
+
fonts.push(v);
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
return fonts;
|
|
2695
|
+
}
|
|
1649
2696
|
function detectFonts(rootDir, techStack) {
|
|
1650
2697
|
let rawFonts;
|
|
1651
2698
|
switch (techStack) {
|
|
1652
2699
|
case "flutter":
|
|
1653
2700
|
rawFonts = detectFlutterFonts(rootDir);
|
|
1654
2701
|
break;
|
|
2702
|
+
case "expo":
|
|
2703
|
+
rawFonts = detectExpoFonts(rootDir);
|
|
2704
|
+
break;
|
|
1655
2705
|
case "react-native":
|
|
1656
2706
|
case "capacitor":
|
|
1657
2707
|
rawFonts = detectJSFonts(rootDir);
|
|
@@ -1660,9 +2710,11 @@ function detectFonts(rootDir, techStack) {
|
|
|
1660
2710
|
rawFonts = detectSwiftFonts(rootDir);
|
|
1661
2711
|
break;
|
|
1662
2712
|
case "kotlin":
|
|
1663
|
-
case "java":
|
|
1664
2713
|
rawFonts = detectAndroidFonts(rootDir);
|
|
1665
2714
|
break;
|
|
2715
|
+
case "dotnet-maui":
|
|
2716
|
+
rawFonts = detectMauiFonts(rootDir);
|
|
2717
|
+
break;
|
|
1666
2718
|
default:
|
|
1667
2719
|
rawFonts = [];
|
|
1668
2720
|
}
|
|
@@ -1681,8 +2733,8 @@ function detectFonts(rootDir, techStack) {
|
|
|
1681
2733
|
}
|
|
1682
2734
|
|
|
1683
2735
|
// src/analyzers/source-reader.ts
|
|
1684
|
-
import { readdirSync as
|
|
1685
|
-
import { join as join7, relative as
|
|
2736
|
+
import { readdirSync as readdirSync7, statSync as statSync4 } from "fs";
|
|
2737
|
+
import { join as join7, relative as relative3 } from "path";
|
|
1686
2738
|
var README_NAMES = [
|
|
1687
2739
|
"README.md",
|
|
1688
2740
|
"readme.md",
|
|
@@ -1728,7 +2780,7 @@ function readReadme(rootDir) {
|
|
|
1728
2780
|
}
|
|
1729
2781
|
return null;
|
|
1730
2782
|
}
|
|
1731
|
-
function
|
|
2783
|
+
function readSourceFiles(rootDir, techStack, maxTotalChars = 5e4) {
|
|
1732
2784
|
const extensions = getExtensionsForStack(techStack);
|
|
1733
2785
|
const priorityPatterns = getPriorityPatternsForStack(techStack);
|
|
1734
2786
|
const allFiles = scanDirectory(rootDir, {
|
|
@@ -1737,7 +2789,7 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
1737
2789
|
maxFiles: 500
|
|
1738
2790
|
});
|
|
1739
2791
|
const sorted = sortByPriority(allFiles, priorityPatterns);
|
|
1740
|
-
const
|
|
2792
|
+
const files = [];
|
|
1741
2793
|
let totalChars = 0;
|
|
1742
2794
|
for (const file of sorted) {
|
|
1743
2795
|
if (totalChars >= maxTotalChars) break;
|
|
@@ -1746,27 +2798,36 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
1746
2798
|
if (isTestFile(file)) continue;
|
|
1747
2799
|
if (isGeneratedFile(file, content)) continue;
|
|
1748
2800
|
const truncated = content.substring(0, 15e3);
|
|
1749
|
-
|
|
1750
|
-
${truncated}`);
|
|
2801
|
+
files.push({ path: file, content: truncated });
|
|
1751
2802
|
totalChars += truncated.length;
|
|
1752
2803
|
}
|
|
1753
|
-
return
|
|
2804
|
+
return files;
|
|
2805
|
+
}
|
|
2806
|
+
function joinSourceFiles(files) {
|
|
2807
|
+
return files.map((f) => `--- ${f.path} ---
|
|
2808
|
+
${f.content}`).join("\n\n");
|
|
2809
|
+
}
|
|
2810
|
+
function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
2811
|
+
return joinSourceFiles(readSourceFiles(rootDir, techStack, maxTotalChars));
|
|
1754
2812
|
}
|
|
1755
2813
|
function getExtensionsForStack(techStack) {
|
|
1756
2814
|
switch (techStack) {
|
|
1757
2815
|
case "flutter":
|
|
1758
2816
|
return [".dart"];
|
|
1759
2817
|
case "swift":
|
|
1760
|
-
return [".swift"];
|
|
2818
|
+
return [".swift", ".m", ".mm", ".h"];
|
|
1761
2819
|
case "kotlin":
|
|
1762
2820
|
return [".kt", ".kts"];
|
|
1763
|
-
case "
|
|
1764
|
-
return [".java"];
|
|
2821
|
+
case "expo":
|
|
1765
2822
|
case "react-native":
|
|
1766
2823
|
case "capacitor":
|
|
1767
2824
|
return [".ts", ".tsx", ".js", ".jsx"];
|
|
1768
2825
|
case "dotnet-maui":
|
|
1769
2826
|
return [".cs", ".xaml"];
|
|
2827
|
+
case "unity":
|
|
2828
|
+
return [".cs"];
|
|
2829
|
+
case "kmp":
|
|
2830
|
+
return [".kt", ".kts", ".swift"];
|
|
1770
2831
|
default:
|
|
1771
2832
|
return [".ts", ".js", ".swift", ".dart", ".kt"];
|
|
1772
2833
|
}
|
|
@@ -1792,11 +2853,34 @@ function getPriorityPatternsForStack(techStack) {
|
|
|
1792
2853
|
case "swift":
|
|
1793
2854
|
return [...common, "view", "controller", "manager", "delegate", "contentview"];
|
|
1794
2855
|
case "kotlin":
|
|
1795
|
-
|
|
1796
|
-
|
|
2856
|
+
return [
|
|
2857
|
+
...common,
|
|
2858
|
+
"activity",
|
|
2859
|
+
"fragment",
|
|
2860
|
+
"viewmodel",
|
|
2861
|
+
"repository",
|
|
2862
|
+
// Jetpack Compose deyimleri — Compose-first projelerde UI kodları
|
|
2863
|
+
// bunlar olmadan priority listesinde aşağı sıralanıyordu
|
|
2864
|
+
"composable",
|
|
2865
|
+
"screen",
|
|
2866
|
+
"theme",
|
|
2867
|
+
"navigation",
|
|
2868
|
+
"navgraph",
|
|
2869
|
+
"scaffold",
|
|
2870
|
+
"compose",
|
|
2871
|
+
"material"
|
|
2872
|
+
];
|
|
2873
|
+
case "expo":
|
|
2874
|
+
return [...common, "screen", "component", "hook", "context", "store", "slice", "_layout", "tabs"];
|
|
1797
2875
|
case "react-native":
|
|
1798
2876
|
case "capacitor":
|
|
1799
2877
|
return [...common, "screen", "component", "hook", "context", "store", "slice"];
|
|
2878
|
+
case "dotnet-maui":
|
|
2879
|
+
return [...common, "page", "view", "viewmodel", "service", "mauiprogram", "appshell"];
|
|
2880
|
+
case "unity":
|
|
2881
|
+
return [...common, "gamemanager", "main", "controller", "manager"];
|
|
2882
|
+
case "kmp":
|
|
2883
|
+
return [...common, "app", "mainactivity", "contentview", "viewmodel"];
|
|
1800
2884
|
default:
|
|
1801
2885
|
return common;
|
|
1802
2886
|
}
|
|
@@ -1819,7 +2903,14 @@ function sortByPriority(files, patterns) {
|
|
|
1819
2903
|
}
|
|
1820
2904
|
function isTestFile(file) {
|
|
1821
2905
|
const lower = file.toLowerCase();
|
|
1822
|
-
|
|
2906
|
+
if (/(?:^|[/\\])(?:test|tests|__tests__|spec|specs|androidtest|mocks?|fixtures?)[/\\]/.test(
|
|
2907
|
+
lower
|
|
2908
|
+
)) {
|
|
2909
|
+
return true;
|
|
2910
|
+
}
|
|
2911
|
+
if (/\.(?:test|spec|mock|fixture)\.[a-z]+$/.test(lower)) return true;
|
|
2912
|
+
if (/(?:test|tests|spec|specs)\.[a-z]+$/.test(lower)) return true;
|
|
2913
|
+
return false;
|
|
1823
2914
|
}
|
|
1824
2915
|
function isGeneratedFile(file, content) {
|
|
1825
2916
|
const lower = file.toLowerCase();
|
|
@@ -1836,7 +2927,7 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1836
2927
|
if (depth > maxDepth || entryCount >= maxEntries) return;
|
|
1837
2928
|
let entries;
|
|
1838
2929
|
try {
|
|
1839
|
-
entries =
|
|
2930
|
+
entries = readdirSync7(dir).sort((a, b) => {
|
|
1840
2931
|
const aIsDir = isDir(join7(dir, a));
|
|
1841
2932
|
const bIsDir = isDir(join7(dir, b));
|
|
1842
2933
|
if (aIsDir && !bIsDir) return -1;
|
|
@@ -1878,15 +2969,15 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1878
2969
|
return false;
|
|
1879
2970
|
}
|
|
1880
2971
|
}
|
|
1881
|
-
const rootName =
|
|
2972
|
+
const rootName = relative3(join7(rootDir, ".."), rootDir) || "project";
|
|
1882
2973
|
lines.push(`${rootName}/`);
|
|
1883
2974
|
walk(rootDir, "", 0);
|
|
1884
2975
|
return lines.join("\n");
|
|
1885
2976
|
}
|
|
1886
2977
|
|
|
1887
2978
|
// src/analyzers/asset-scanner.ts
|
|
1888
|
-
import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as
|
|
1889
|
-
import { join as join8, extname as extname3, relative as
|
|
2979
|
+
import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as readdirSync8, statSync as statSync5 } from "fs";
|
|
2980
|
+
import { join as join8, extname as extname3, relative as relative4, basename as basename2 } from "path";
|
|
1890
2981
|
var MAX_ASSET_SIZE = 5 * 1024 * 1024;
|
|
1891
2982
|
var MAX_TOTAL_ASSETS = 10;
|
|
1892
2983
|
var MAX_SCREENSHOTS = 5;
|
|
@@ -1917,7 +3008,7 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
|
|
|
1917
3008
|
base64_data: buffer.toString("base64"),
|
|
1918
3009
|
width: null,
|
|
1919
3010
|
height: null,
|
|
1920
|
-
source_path:
|
|
3011
|
+
source_path: relative4(rootDir, filePath)
|
|
1921
3012
|
};
|
|
1922
3013
|
} catch {
|
|
1923
3014
|
return null;
|
|
@@ -1927,7 +3018,7 @@ function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, m
|
|
|
1927
3018
|
if (currentDepth > maxDepth) return;
|
|
1928
3019
|
if (!existsSync5(dirPath)) return;
|
|
1929
3020
|
try {
|
|
1930
|
-
const entries =
|
|
3021
|
+
const entries = readdirSync8(dirPath);
|
|
1931
3022
|
for (const entry of entries) {
|
|
1932
3023
|
if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
|
|
1933
3024
|
if (assets.length >= MAX_TOTAL_ASSETS) return;
|
|
@@ -1976,6 +3067,17 @@ function getScreenshotDirs(rootDir, techStack) {
|
|
|
1976
3067
|
gen(join8(rootDir, "assets"))
|
|
1977
3068
|
);
|
|
1978
3069
|
break;
|
|
3070
|
+
case "expo":
|
|
3071
|
+
dirs.push(
|
|
3072
|
+
ss(join8(rootDir, "assets/screenshots")),
|
|
3073
|
+
ss(join8(rootDir, "src/assets/screenshots")),
|
|
3074
|
+
ss(join8(rootDir, "docs/screenshots")),
|
|
3075
|
+
gen(join8(rootDir, "assets/images")),
|
|
3076
|
+
gen(join8(rootDir, "assets")),
|
|
3077
|
+
gen(join8(rootDir, "src/assets/images")),
|
|
3078
|
+
gen(join8(rootDir, "src/assets"))
|
|
3079
|
+
);
|
|
3080
|
+
break;
|
|
1979
3081
|
case "react-native":
|
|
1980
3082
|
case "capacitor":
|
|
1981
3083
|
dirs.push(
|
|
@@ -1996,7 +3098,6 @@ function getScreenshotDirs(rootDir, techStack) {
|
|
|
1996
3098
|
);
|
|
1997
3099
|
break;
|
|
1998
3100
|
case "kotlin":
|
|
1999
|
-
case "java":
|
|
2000
3101
|
dirs.push(
|
|
2001
3102
|
ss(join8(rootDir, "fastlane/metadata/android/en-US/images/phoneScreenshots")),
|
|
2002
3103
|
gen(join8(rootDir, "metadata/android/en-US/images")),
|
|
@@ -2004,6 +3105,15 @@ function getScreenshotDirs(rootDir, techStack) {
|
|
|
2004
3105
|
gen(join8(rootDir, "Marketing"))
|
|
2005
3106
|
);
|
|
2006
3107
|
break;
|
|
3108
|
+
case "dotnet-maui":
|
|
3109
|
+
dirs.push(
|
|
3110
|
+
ss(join8(rootDir, "Resources/Images/screenshots")),
|
|
3111
|
+
ss(join8(rootDir, "Resources/Screenshots")),
|
|
3112
|
+
gen(join8(rootDir, "Resources/Images")),
|
|
3113
|
+
gen(join8(rootDir, "marketing")),
|
|
3114
|
+
gen(join8(rootDir, "Marketing"))
|
|
3115
|
+
);
|
|
3116
|
+
break;
|
|
2007
3117
|
}
|
|
2008
3118
|
return dirs;
|
|
2009
3119
|
}
|
|
@@ -2030,6 +3140,31 @@ function getSplashPaths(rootDir, techStack) {
|
|
|
2030
3140
|
);
|
|
2031
3141
|
}
|
|
2032
3142
|
break;
|
|
3143
|
+
case "expo":
|
|
3144
|
+
paths.push(
|
|
3145
|
+
join8(rootDir, "assets/splash.png"),
|
|
3146
|
+
join8(rootDir, "assets/splash-icon.png"),
|
|
3147
|
+
join8(rootDir, "assets/images/splash.png"),
|
|
3148
|
+
join8(rootDir, "assets/images/splash-icon.png"),
|
|
3149
|
+
join8(rootDir, "src/assets/splash.png")
|
|
3150
|
+
);
|
|
3151
|
+
const expoSplashContent = readFileSafe(join8(rootDir, "app.json"));
|
|
3152
|
+
if (expoSplashContent) {
|
|
3153
|
+
try {
|
|
3154
|
+
const data = JSON.parse(expoSplashContent);
|
|
3155
|
+
const expo = data.expo || data;
|
|
3156
|
+
const candidates = [
|
|
3157
|
+
expo.splash?.image,
|
|
3158
|
+
expo.android?.splash?.image,
|
|
3159
|
+
expo.ios?.splash?.image
|
|
3160
|
+
].filter((v) => typeof v === "string");
|
|
3161
|
+
for (const rel of candidates) {
|
|
3162
|
+
paths.push(join8(rootDir, rel.replace(/^\.?\//, "")));
|
|
3163
|
+
}
|
|
3164
|
+
} catch {
|
|
3165
|
+
}
|
|
3166
|
+
}
|
|
3167
|
+
break;
|
|
2033
3168
|
case "react-native":
|
|
2034
3169
|
case "capacitor":
|
|
2035
3170
|
paths.push(
|
|
@@ -2040,7 +3175,6 @@ function getSplashPaths(rootDir, techStack) {
|
|
|
2040
3175
|
);
|
|
2041
3176
|
break;
|
|
2042
3177
|
case "kotlin":
|
|
2043
|
-
case "java":
|
|
2044
3178
|
for (const density of ["xxxhdpi", "xxhdpi", "xhdpi"]) {
|
|
2045
3179
|
paths.push(
|
|
2046
3180
|
join8(rootDir, `app/src/main/res/drawable-${density}/splash.png`),
|
|
@@ -2048,6 +3182,14 @@ function getSplashPaths(rootDir, techStack) {
|
|
|
2048
3182
|
);
|
|
2049
3183
|
}
|
|
2050
3184
|
break;
|
|
3185
|
+
case "dotnet-maui":
|
|
3186
|
+
paths.push(
|
|
3187
|
+
join8(rootDir, "Resources/Splash/splash.png"),
|
|
3188
|
+
join8(rootDir, "Resources/Splash/splashscreen.png"),
|
|
3189
|
+
join8(rootDir, "Resources/Splash/splash.svg"),
|
|
3190
|
+
join8(rootDir, "Resources/Images/splash.png")
|
|
3191
|
+
);
|
|
3192
|
+
break;
|
|
2051
3193
|
}
|
|
2052
3194
|
return paths;
|
|
2053
3195
|
}
|
|
@@ -2060,19 +3202,26 @@ function getFeatureGraphicPaths(rootDir, techStack) {
|
|
|
2060
3202
|
);
|
|
2061
3203
|
switch (techStack) {
|
|
2062
3204
|
case "flutter":
|
|
3205
|
+
case "expo":
|
|
2063
3206
|
case "react-native":
|
|
2064
3207
|
case "capacitor":
|
|
2065
3208
|
paths.push(
|
|
2066
3209
|
join8(rootDir, "assets/feature_graphic.png"),
|
|
2067
|
-
join8(rootDir, "assets/feature-graphic.png")
|
|
3210
|
+
join8(rootDir, "assets/feature-graphic.png"),
|
|
3211
|
+
join8(rootDir, "assets/images/feature_graphic.png")
|
|
2068
3212
|
);
|
|
2069
3213
|
break;
|
|
2070
3214
|
case "kotlin":
|
|
2071
|
-
case "java":
|
|
2072
3215
|
paths.push(
|
|
2073
3216
|
join8(rootDir, "app/src/main/feature_graphic.png")
|
|
2074
3217
|
);
|
|
2075
3218
|
break;
|
|
3219
|
+
case "dotnet-maui":
|
|
3220
|
+
paths.push(
|
|
3221
|
+
join8(rootDir, "Resources/Images/feature_graphic.png"),
|
|
3222
|
+
join8(rootDir, "Resources/Images/feature-graphic.png")
|
|
3223
|
+
);
|
|
3224
|
+
break;
|
|
2076
3225
|
}
|
|
2077
3226
|
return paths;
|
|
2078
3227
|
}
|
|
@@ -2478,7 +3627,7 @@ async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
|
|
|
2478
3627
|
mime_type: mimeForPath(appIconPath),
|
|
2479
3628
|
width: null,
|
|
2480
3629
|
height: null,
|
|
2481
|
-
source_path:
|
|
3630
|
+
source_path: relative5(rootDir, appIconPath)
|
|
2482
3631
|
},
|
|
2483
3632
|
buffer
|
|
2484
3633
|
});
|
|
@@ -2607,7 +3756,7 @@ async function analyzeCommand(options) {
|
|
|
2607
3756
|
if (techStack.stack === "unknown") {
|
|
2608
3757
|
techSpinner.fail(
|
|
2609
3758
|
chalk.red(
|
|
2610
|
-
"No supported project found. Supported: Swift, Flutter, React Native, Kotlin, Capacitor, .NET MAUI"
|
|
3759
|
+
"No supported project found. Supported: Swift, Flutter, React Native, Expo, Kotlin, Capacitor, .NET MAUI"
|
|
2611
3760
|
)
|
|
2612
3761
|
);
|
|
2613
3762
|
process.exit(1);
|
|
@@ -3019,20 +4168,1584 @@ import { createInterface as createInterface2 } from "readline";
|
|
|
3019
4168
|
import chalk3 from "chalk";
|
|
3020
4169
|
import ora2 from "ora";
|
|
3021
4170
|
|
|
3022
|
-
// src/
|
|
3023
|
-
|
|
3024
|
-
return `You are an expert app analyst. Your job is to understand WHAT an app does from the USER's perspective by reading its source code. You are NOT reviewing code quality.
|
|
4171
|
+
// src/engine/index.ts
|
|
4172
|
+
import { join as join14 } from "path";
|
|
3025
4173
|
|
|
3026
|
-
|
|
4174
|
+
// src/engine/registry.ts
|
|
4175
|
+
var ECOSYSTEM_KEYS = [
|
|
4176
|
+
"npm",
|
|
4177
|
+
"pub",
|
|
4178
|
+
"pods",
|
|
4179
|
+
"spm",
|
|
4180
|
+
"gradle",
|
|
4181
|
+
"nuget",
|
|
4182
|
+
"upm",
|
|
4183
|
+
"ios_imports",
|
|
4184
|
+
"android_imports"
|
|
4185
|
+
];
|
|
4186
|
+
function matchDefault(pattern, dep, caseInsensitive) {
|
|
4187
|
+
const p = caseInsensitive ? pattern.toLowerCase() : pattern;
|
|
4188
|
+
const d = caseInsensitive ? dep.toLowerCase() : dep;
|
|
4189
|
+
if (p.endsWith("*")) return d.startsWith(p.slice(0, -1));
|
|
4190
|
+
return d === p;
|
|
4191
|
+
}
|
|
4192
|
+
function matchGradle(pattern, dep) {
|
|
4193
|
+
if (pattern.endsWith("*")) {
|
|
4194
|
+
const prefix = pattern.slice(0, -1);
|
|
4195
|
+
if (prefix === "") return false;
|
|
4196
|
+
if (prefix.includes(":")) return dep.startsWith(prefix);
|
|
4197
|
+
if (!dep.includes(":")) return dep.startsWith(prefix);
|
|
4198
|
+
const idx2 = dep.lastIndexOf(":");
|
|
4199
|
+
return dep.slice(idx2 + 1).startsWith(prefix);
|
|
4200
|
+
}
|
|
4201
|
+
if (pattern.includes(":")) return dep === pattern;
|
|
4202
|
+
if (dep === pattern) return true;
|
|
4203
|
+
const idx = dep.lastIndexOf(":");
|
|
4204
|
+
return idx >= 0 && dep.slice(idx + 1) === pattern;
|
|
4205
|
+
}
|
|
4206
|
+
function matchImportPrefix(pattern, dep) {
|
|
4207
|
+
return dep === pattern || dep.startsWith(`${pattern}.`);
|
|
4208
|
+
}
|
|
4209
|
+
function ecosystemMatches(key, pattern, dep) {
|
|
4210
|
+
switch (key) {
|
|
4211
|
+
case "gradle":
|
|
4212
|
+
return matchGradle(pattern, dep);
|
|
4213
|
+
case "ios_imports":
|
|
4214
|
+
case "android_imports":
|
|
4215
|
+
return matchImportPrefix(pattern, dep);
|
|
4216
|
+
case "pods":
|
|
4217
|
+
case "spm":
|
|
4218
|
+
return matchDefault(pattern, dep, true);
|
|
4219
|
+
default:
|
|
4220
|
+
return matchDefault(pattern, dep, false);
|
|
4221
|
+
}
|
|
4222
|
+
}
|
|
4223
|
+
function matchSDKs(deps, registry) {
|
|
4224
|
+
const results = [];
|
|
4225
|
+
for (const entry of registry) {
|
|
4226
|
+
const coordinates = /* @__PURE__ */ new Set();
|
|
4227
|
+
for (const key of ECOSYSTEM_KEYS) {
|
|
4228
|
+
const patterns = entry.match[key];
|
|
4229
|
+
if (!patterns || patterns.length === 0) continue;
|
|
4230
|
+
const bucket = deps[key];
|
|
4231
|
+
if (!bucket || bucket.length === 0) continue;
|
|
4232
|
+
for (const pattern of patterns) {
|
|
4233
|
+
for (const dep of bucket) {
|
|
4234
|
+
if (ecosystemMatches(key, pattern, dep)) {
|
|
4235
|
+
coordinates.add(`${key}:${dep}`);
|
|
4236
|
+
}
|
|
4237
|
+
}
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
if (coordinates.size > 0) {
|
|
4241
|
+
results.push({
|
|
4242
|
+
id: entry.id,
|
|
4243
|
+
name: entry.name,
|
|
4244
|
+
category: entry.category,
|
|
4245
|
+
matched_coordinates: Array.from(coordinates).sort(),
|
|
4246
|
+
data_collection: entry.data_collection,
|
|
4247
|
+
flags: entry.flags
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
}
|
|
4251
|
+
return results.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
4252
|
+
}
|
|
3027
4253
|
|
|
3028
|
-
|
|
4254
|
+
// src/engine/extractors/ios-plist.ts
|
|
4255
|
+
import { join as join9, relative as relative6 } from "path";
|
|
4256
|
+
import plistLib from "plist";
|
|
3029
4257
|
|
|
3030
|
-
|
|
4258
|
+
// src/engine/extractors/shared.ts
|
|
4259
|
+
var EXTENSION_SUFFIXES2 = [
|
|
4260
|
+
"Extension",
|
|
4261
|
+
"Widget",
|
|
4262
|
+
"WidgetExtension",
|
|
4263
|
+
"Intent",
|
|
4264
|
+
"IntentExtension",
|
|
4265
|
+
"NotificationService",
|
|
4266
|
+
"NotificationContent",
|
|
4267
|
+
"ShieldConfiguration",
|
|
4268
|
+
"ShieldConfigurationExtension",
|
|
4269
|
+
"ShieldAction",
|
|
4270
|
+
"ShieldActionExtension",
|
|
4271
|
+
"WatchKit",
|
|
4272
|
+
"Watch",
|
|
4273
|
+
"Clip",
|
|
4274
|
+
"Tests",
|
|
4275
|
+
"UITests",
|
|
4276
|
+
"StickerPack",
|
|
4277
|
+
"ShareExtension",
|
|
4278
|
+
"TodayExtension",
|
|
4279
|
+
"KeyboardExtension"
|
|
4280
|
+
];
|
|
4281
|
+
function isExtensionPath(relPath) {
|
|
4282
|
+
const segments = relPath.split(/[\\/]/);
|
|
4283
|
+
return segments.some((segment, idx) => {
|
|
4284
|
+
if (idx === segments.length - 1) return false;
|
|
4285
|
+
if (!segment) return false;
|
|
4286
|
+
return EXTENSION_SUFFIXES2.some(
|
|
4287
|
+
(suffix) => segment.endsWith(suffix) || segment.toLowerCase().endsWith(suffix.toLowerCase())
|
|
4288
|
+
);
|
|
4289
|
+
});
|
|
4290
|
+
}
|
|
4291
|
+
function isXcodeVariable2(value) {
|
|
4292
|
+
return typeof value === "string" && (value.includes("$(") || value.includes("${"));
|
|
4293
|
+
}
|
|
4294
|
+
function normalizeStringValue(value) {
|
|
4295
|
+
if (typeof value !== "string") return null;
|
|
4296
|
+
if (value.length === 0) return null;
|
|
4297
|
+
if (isXcodeVariable2(value)) return null;
|
|
4298
|
+
return value;
|
|
4299
|
+
}
|
|
4300
|
+
function parsePlistBoolean(value) {
|
|
4301
|
+
if (typeof value === "boolean") return value;
|
|
4302
|
+
if (typeof value === "string") {
|
|
4303
|
+
const normalized = value.trim().toLowerCase();
|
|
4304
|
+
if (normalized === "yes" || normalized === "true" || normalized === "1") return true;
|
|
4305
|
+
if (normalized === "no" || normalized === "false" || normalized === "0") return false;
|
|
4306
|
+
}
|
|
4307
|
+
return null;
|
|
4308
|
+
}
|
|
4309
|
+
function pathDepth(relPath) {
|
|
4310
|
+
return relPath.split(/[\\/]/).length;
|
|
4311
|
+
}
|
|
3031
4312
|
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
4313
|
+
// src/engine/extractors/ios-plist.ts
|
|
4314
|
+
var NAMED_FIELD_KEYS = /* @__PURE__ */ new Set([
|
|
4315
|
+
"UIBackgroundModes",
|
|
4316
|
+
"NSAppTransportSecurity",
|
|
4317
|
+
"GADApplicationIdentifier",
|
|
4318
|
+
"SKAdNetworkItems",
|
|
4319
|
+
"ITSAppUsesNonExemptEncryption",
|
|
4320
|
+
"UIRequiredDeviceCapabilities"
|
|
4321
|
+
]);
|
|
4322
|
+
function findMainInfoPlist(rootDir) {
|
|
4323
|
+
const candidates = findAllFiles(rootDir, "Info.plist", 4);
|
|
4324
|
+
const nonExtension = candidates.filter(
|
|
4325
|
+
(abs) => !isExtensionPath(relative6(rootDir, abs))
|
|
4326
|
+
);
|
|
4327
|
+
if (nonExtension.length === 0) return null;
|
|
4328
|
+
return [...nonExtension].sort(
|
|
4329
|
+
(a, b) => pathDepth(relative6(rootDir, a)) - pathDepth(relative6(rootDir, b))
|
|
4330
|
+
)[0];
|
|
4331
|
+
}
|
|
4332
|
+
function readInfoPlistFile(filePath) {
|
|
4333
|
+
const content = readFileSafe(filePath);
|
|
4334
|
+
if (!content) return null;
|
|
4335
|
+
try {
|
|
4336
|
+
const parsed = plistLib.parse(content);
|
|
4337
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4338
|
+
return parsed;
|
|
4339
|
+
}
|
|
4340
|
+
return null;
|
|
4341
|
+
} catch {
|
|
4342
|
+
return null;
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4345
|
+
function readPbxprojInfoplistKeys(content) {
|
|
4346
|
+
const keys = {};
|
|
4347
|
+
const keyPattern = /INFOPLIST_KEY_([A-Za-z0-9_]+)\s*=\s*"?([^";]+)"?;/g;
|
|
4348
|
+
for (const match of content.matchAll(keyPattern)) {
|
|
4349
|
+
keys[match[1]] = match[2].trim();
|
|
4350
|
+
}
|
|
4351
|
+
const generatesInfoPlist = /GENERATE_INFOPLIST_FILE\s*=\s*YES/.test(content);
|
|
4352
|
+
return { keys, generatesInfoPlist };
|
|
4353
|
+
}
|
|
4354
|
+
function findExpoGadAppId(appJson) {
|
|
4355
|
+
const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
4356
|
+
const asId = (v) => typeof v === "string" && v.length > 0 ? v : null;
|
|
4357
|
+
const root = asRecord(appJson);
|
|
4358
|
+
if (!root) return null;
|
|
4359
|
+
const expo = asRecord(root.expo);
|
|
4360
|
+
const legacy = asId(asRecord(asRecord(expo?.ios)?.config)?.googleMobileAdsAppId);
|
|
4361
|
+
if (legacy) return legacy;
|
|
4362
|
+
const rnTop = asId(asRecord(root["react-native-google-mobile-ads"])?.ios_app_id);
|
|
4363
|
+
if (rnTop) return rnTop;
|
|
4364
|
+
const plugins = expo?.plugins;
|
|
4365
|
+
if (Array.isArray(plugins)) {
|
|
4366
|
+
for (const entry of plugins) {
|
|
4367
|
+
if (!Array.isArray(entry) || entry[0] !== "react-native-google-mobile-ads") continue;
|
|
4368
|
+
const props = asRecord(entry[1]);
|
|
4369
|
+
const id = asId(props?.iosAppId) ?? asId(props?.ios_app_id);
|
|
4370
|
+
if (id) return id;
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
return null;
|
|
4374
|
+
}
|
|
4375
|
+
function readExpoConfigFallback(content) {
|
|
4376
|
+
const found = {};
|
|
4377
|
+
for (const m of content.matchAll(
|
|
4378
|
+
/(NS[A-Za-z]+UsageDescription)\s*:\s*["']([^"']*)["']/g
|
|
4379
|
+
)) {
|
|
4380
|
+
found[m[1]] = m[2];
|
|
4381
|
+
}
|
|
4382
|
+
const gad = content.match(/GADApplicationIdentifier\s*:\s*["']([^"']*)["']/);
|
|
4383
|
+
if (gad) found.GADApplicationIdentifier = gad[1];
|
|
4384
|
+
return found;
|
|
4385
|
+
}
|
|
4386
|
+
function extractPlistFields(merged) {
|
|
4387
|
+
const usageDescriptions = {};
|
|
4388
|
+
for (const key of Object.keys(merged)) {
|
|
4389
|
+
if (/UsageDescription$/.test(key)) {
|
|
4390
|
+
usageDescriptions[key] = normalizeStringValue(merged[key]);
|
|
4391
|
+
}
|
|
4392
|
+
}
|
|
4393
|
+
const backgroundModes = Array.isArray(merged.UIBackgroundModes) ? merged.UIBackgroundModes.filter((v) => typeof v === "string") : [];
|
|
4394
|
+
let atsAllowsArbitraryLoads = null;
|
|
4395
|
+
const ats = merged.NSAppTransportSecurity;
|
|
4396
|
+
if (ats && typeof ats === "object" && !Array.isArray(ats)) {
|
|
4397
|
+
const atsDict = ats;
|
|
4398
|
+
if ("NSAllowsArbitraryLoads" in atsDict) {
|
|
4399
|
+
atsAllowsArbitraryLoads = parsePlistBoolean(atsDict.NSAllowsArbitraryLoads);
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
const skAdNetworkCount = Array.isArray(merged.SKAdNetworkItems) ? merged.SKAdNetworkItems.length : 0;
|
|
4403
|
+
const requiredDeviceCapabilities = Array.isArray(merged.UIRequiredDeviceCapabilities) ? merged.UIRequiredDeviceCapabilities.filter((v) => typeof v === "string") : [];
|
|
4404
|
+
const otherKeys = Array.from(
|
|
4405
|
+
new Set(
|
|
4406
|
+
Object.keys(merged).filter(
|
|
4407
|
+
(k) => !NAMED_FIELD_KEYS.has(k) && !/UsageDescription$/.test(k)
|
|
4408
|
+
)
|
|
4409
|
+
)
|
|
4410
|
+
).sort();
|
|
4411
|
+
const fields = {
|
|
4412
|
+
usage_descriptions: usageDescriptions,
|
|
4413
|
+
background_modes: backgroundModes,
|
|
4414
|
+
ats_allows_arbitrary_loads: atsAllowsArbitraryLoads,
|
|
4415
|
+
sk_ad_network_count: skAdNetworkCount,
|
|
4416
|
+
required_device_capabilities: requiredDeviceCapabilities,
|
|
4417
|
+
other_keys: otherKeys
|
|
4418
|
+
};
|
|
4419
|
+
if ("GADApplicationIdentifier" in merged) {
|
|
4420
|
+
fields.gad_application_identifier = normalizeStringValue(merged.GADApplicationIdentifier);
|
|
4421
|
+
}
|
|
4422
|
+
if ("ITSAppUsesNonExemptEncryption" in merged) {
|
|
4423
|
+
fields.non_exempt_encryption = parsePlistBoolean(merged.ITSAppUsesNonExemptEncryption);
|
|
4424
|
+
}
|
|
4425
|
+
return fields;
|
|
4426
|
+
}
|
|
4427
|
+
function extractIosPlist(rootDir) {
|
|
4428
|
+
const evidence = {};
|
|
4429
|
+
const plistFilePath = findMainInfoPlist(rootDir);
|
|
4430
|
+
const plistFileData = plistFilePath ? readInfoPlistFile(plistFilePath) : null;
|
|
4431
|
+
if (plistFilePath && plistFileData) {
|
|
4432
|
+
evidence["ios.plist.plist_file"] = { file: relative6(rootDir, plistFilePath) };
|
|
4433
|
+
}
|
|
4434
|
+
const pbxprojPath = findFile(rootDir, "project.pbxproj", 5);
|
|
4435
|
+
let pbxprojData = null;
|
|
4436
|
+
if (pbxprojPath) {
|
|
4437
|
+
const content = readFileSafe(pbxprojPath);
|
|
4438
|
+
if (content) {
|
|
4439
|
+
const { keys, generatesInfoPlist } = readPbxprojInfoplistKeys(content);
|
|
4440
|
+
if (Object.keys(keys).length > 0 || generatesInfoPlist) {
|
|
4441
|
+
pbxprojData = keys;
|
|
4442
|
+
evidence["ios.plist.pbxproj_infoplist_keys"] = {
|
|
4443
|
+
file: relative6(rootDir, pbxprojPath),
|
|
4444
|
+
detail: generatesInfoPlist ? "GENERATE_INFOPLIST_FILE = YES" : void 0
|
|
4445
|
+
};
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
}
|
|
4449
|
+
let expoData = null;
|
|
4450
|
+
const appJsonContent = readFileSafe(join9(rootDir, "app.json"));
|
|
4451
|
+
if (appJsonContent) {
|
|
4452
|
+
try {
|
|
4453
|
+
const appJson = JSON.parse(appJsonContent);
|
|
4454
|
+
const infoPlist = appJson?.expo?.ios?.infoPlist;
|
|
4455
|
+
if (infoPlist && typeof infoPlist === "object" && !Array.isArray(infoPlist)) {
|
|
4456
|
+
expoData = { ...infoPlist };
|
|
4457
|
+
if (!("GADApplicationIdentifier" in expoData)) {
|
|
4458
|
+
const gadId = findExpoGadAppId(appJson);
|
|
4459
|
+
if (gadId !== null) expoData.GADApplicationIdentifier = gadId;
|
|
4460
|
+
}
|
|
4461
|
+
evidence["ios.plist.expo_config"] = { file: "app.json" };
|
|
4462
|
+
}
|
|
4463
|
+
} catch {
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
if (!expoData) {
|
|
4467
|
+
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
4468
|
+
const content = readFileSafe(join9(rootDir, name));
|
|
4469
|
+
if (!content) continue;
|
|
4470
|
+
const found = readExpoConfigFallback(content);
|
|
4471
|
+
if (Object.keys(found).length > 0) {
|
|
4472
|
+
expoData = found;
|
|
4473
|
+
evidence["ios.plist.expo_config"] = {
|
|
4474
|
+
file: name,
|
|
4475
|
+
detail: "best-effort regex extraction (app.config.js/ts)"
|
|
4476
|
+
};
|
|
4477
|
+
}
|
|
4478
|
+
break;
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
const contributing = [];
|
|
4482
|
+
if (expoData) contributing.push("expo_config");
|
|
4483
|
+
if (pbxprojData) contributing.push("pbxproj_infoplist_keys");
|
|
4484
|
+
if (plistFileData) contributing.push("plist_file");
|
|
4485
|
+
if (contributing.length === 0) {
|
|
4486
|
+
return { plist_source: "none", plist: null, evidence };
|
|
4487
|
+
}
|
|
4488
|
+
const merged = {
|
|
4489
|
+
...expoData ?? {},
|
|
4490
|
+
...pbxprojData ?? {},
|
|
4491
|
+
...plistFileData ?? {}
|
|
4492
|
+
};
|
|
4493
|
+
return {
|
|
4494
|
+
plist_source: contributing.length > 1 ? "merged" : contributing[0],
|
|
4495
|
+
plist: extractPlistFields(merged),
|
|
4496
|
+
evidence
|
|
4497
|
+
};
|
|
4498
|
+
}
|
|
4499
|
+
|
|
4500
|
+
// src/engine/extractors/ios-entitlements.ts
|
|
4501
|
+
import { relative as relative7 } from "path";
|
|
4502
|
+
import plistLib2 from "plist";
|
|
4503
|
+
function extractIosEntitlements(rootDir) {
|
|
4504
|
+
const evidence = {};
|
|
4505
|
+
const allFiles = findFiles(rootDir, [".entitlements"], 5);
|
|
4506
|
+
if (allFiles.length === 0) {
|
|
4507
|
+
return { entitlements: null, evidence };
|
|
4508
|
+
}
|
|
4509
|
+
const nonExtensionFiles = allFiles.filter(
|
|
4510
|
+
(abs) => !isExtensionPath(relative7(rootDir, abs))
|
|
4511
|
+
);
|
|
4512
|
+
const usedExtensionOnly = nonExtensionFiles.length === 0;
|
|
4513
|
+
const files = (usedExtensionOnly ? allFiles : nonExtensionFiles).sort();
|
|
4514
|
+
let apsEnvironment = null;
|
|
4515
|
+
const associatedDomains = /* @__PURE__ */ new Set();
|
|
4516
|
+
let healthkit = false;
|
|
4517
|
+
let signInWithApple = false;
|
|
4518
|
+
const appGroups = /* @__PURE__ */ new Set();
|
|
4519
|
+
const keys = /* @__PURE__ */ new Set();
|
|
4520
|
+
for (const file of files) {
|
|
4521
|
+
const content = readFileSafe(file);
|
|
4522
|
+
if (!content) continue;
|
|
4523
|
+
let parsed;
|
|
4524
|
+
try {
|
|
4525
|
+
const value = plistLib2.parse(content);
|
|
4526
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
4527
|
+
parsed = value;
|
|
4528
|
+
} catch {
|
|
4529
|
+
continue;
|
|
4530
|
+
}
|
|
4531
|
+
for (const key of Object.keys(parsed)) keys.add(key);
|
|
4532
|
+
if (apsEnvironment === null) {
|
|
4533
|
+
const aps = parsed["aps-environment"];
|
|
4534
|
+
if (typeof aps === "string" && aps.length > 0) apsEnvironment = aps;
|
|
4535
|
+
}
|
|
4536
|
+
const domains = parsed["com.apple.developer.associated-domains"];
|
|
4537
|
+
if (Array.isArray(domains)) {
|
|
4538
|
+
for (const d of domains) if (typeof d === "string") associatedDomains.add(d);
|
|
4539
|
+
}
|
|
4540
|
+
if (parsed["com.apple.developer.healthkit"]) healthkit = true;
|
|
4541
|
+
const appleSignIn = parsed["com.apple.developer.applesignin"];
|
|
4542
|
+
if (Array.isArray(appleSignIn) && appleSignIn.length > 0) signInWithApple = true;
|
|
4543
|
+
const groups = parsed["com.apple.security.application-groups"];
|
|
4544
|
+
if (Array.isArray(groups)) {
|
|
4545
|
+
for (const g of groups) if (typeof g === "string") appGroups.add(g);
|
|
4546
|
+
}
|
|
4547
|
+
}
|
|
4548
|
+
evidence["ios.entitlements"] = {
|
|
4549
|
+
file: files.map((f) => relative7(rootDir, f)).join(", "),
|
|
4550
|
+
detail: usedExtensionOnly ? `${files.length} extension-target entitlements file(s) - no main-target entitlements found` : `${files.length} entitlements file(s) merged`
|
|
4551
|
+
};
|
|
4552
|
+
return {
|
|
4553
|
+
entitlements: {
|
|
4554
|
+
aps_environment: apsEnvironment,
|
|
4555
|
+
associated_domains: Array.from(associatedDomains).sort(),
|
|
4556
|
+
healthkit,
|
|
4557
|
+
sign_in_with_apple: signInWithApple,
|
|
4558
|
+
app_groups: Array.from(appGroups).sort(),
|
|
4559
|
+
keys: Array.from(keys).sort()
|
|
4560
|
+
},
|
|
4561
|
+
evidence
|
|
4562
|
+
};
|
|
4563
|
+
}
|
|
4564
|
+
|
|
4565
|
+
// src/engine/extractors/ios-privacy-manifest.ts
|
|
4566
|
+
import { relative as relative8 } from "path";
|
|
4567
|
+
import plistLib3 from "plist";
|
|
4568
|
+
function emptyPrivacyManifest() {
|
|
4569
|
+
return {
|
|
4570
|
+
app_manifest_present: false,
|
|
4571
|
+
collected_data_types: [],
|
|
4572
|
+
accessed_api_types: [],
|
|
4573
|
+
tracking: null,
|
|
4574
|
+
tracking_domains: [],
|
|
4575
|
+
sdk_manifests_found: 0
|
|
4576
|
+
};
|
|
4577
|
+
}
|
|
4578
|
+
function extractIosPrivacyManifest(rootDir) {
|
|
4579
|
+
const evidence = {};
|
|
4580
|
+
const allFiles = findAllFiles(rootDir, "PrivacyInfo.xcprivacy", 6);
|
|
4581
|
+
if (allFiles.length === 0) {
|
|
4582
|
+
return { privacy_manifest: emptyPrivacyManifest(), evidence };
|
|
4583
|
+
}
|
|
4584
|
+
const sorted = [...allFiles].sort(
|
|
4585
|
+
(a, b) => pathDepth(relative8(rootDir, a)) - pathDepth(relative8(rootDir, b))
|
|
4586
|
+
);
|
|
4587
|
+
const appManifestPath = sorted[0];
|
|
4588
|
+
const otherCount = sorted.length - 1;
|
|
4589
|
+
evidence["ios.privacy_manifest"] = { file: relative8(rootDir, appManifestPath) };
|
|
4590
|
+
if (otherCount > 0) {
|
|
4591
|
+
evidence["ios.privacy_manifest.sdk_manifests_found"] = {
|
|
4592
|
+
file: sorted.slice(1).map((f) => relative8(rootDir, f)).join(", "),
|
|
4593
|
+
detail: `${otherCount} other .xcprivacy file(s) visible in the scanned tree`
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
const content = readFileSafe(appManifestPath);
|
|
4597
|
+
let parsed = null;
|
|
4598
|
+
if (content) {
|
|
4599
|
+
try {
|
|
4600
|
+
const value = plistLib3.parse(content);
|
|
4601
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
4602
|
+
parsed = value;
|
|
4603
|
+
}
|
|
4604
|
+
} catch {
|
|
4605
|
+
parsed = null;
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
if (!parsed) {
|
|
4609
|
+
return {
|
|
4610
|
+
privacy_manifest: { ...emptyPrivacyManifest(), app_manifest_present: true, sdk_manifests_found: otherCount },
|
|
4611
|
+
evidence
|
|
4612
|
+
};
|
|
4613
|
+
}
|
|
4614
|
+
const collectedDataTypes = [];
|
|
4615
|
+
if (Array.isArray(parsed.NSPrivacyCollectedDataTypes)) {
|
|
4616
|
+
for (const entry of parsed.NSPrivacyCollectedDataTypes) {
|
|
4617
|
+
if (entry && typeof entry === "object") {
|
|
4618
|
+
const type = entry.NSPrivacyCollectedDataType;
|
|
4619
|
+
if (typeof type === "string") collectedDataTypes.push(type);
|
|
4620
|
+
}
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
const accessedApiTypes = [];
|
|
4624
|
+
if (Array.isArray(parsed.NSPrivacyAccessedAPITypes)) {
|
|
4625
|
+
for (const entry of parsed.NSPrivacyAccessedAPITypes) {
|
|
4626
|
+
if (entry && typeof entry === "object") {
|
|
4627
|
+
const type = entry.NSPrivacyAccessedAPIType;
|
|
4628
|
+
if (typeof type === "string") accessedApiTypes.push(type);
|
|
4629
|
+
}
|
|
4630
|
+
}
|
|
4631
|
+
}
|
|
4632
|
+
const tracking = "NSPrivacyTracking" in parsed ? Boolean(parsed.NSPrivacyTracking) : null;
|
|
4633
|
+
const trackingDomains = Array.isArray(parsed.NSPrivacyTrackingDomains) ? parsed.NSPrivacyTrackingDomains.filter((v) => typeof v === "string") : [];
|
|
4634
|
+
return {
|
|
4635
|
+
privacy_manifest: {
|
|
4636
|
+
app_manifest_present: true,
|
|
4637
|
+
collected_data_types: collectedDataTypes,
|
|
4638
|
+
accessed_api_types: accessedApiTypes,
|
|
4639
|
+
tracking,
|
|
4640
|
+
tracking_domains: trackingDomains,
|
|
4641
|
+
sdk_manifests_found: otherCount
|
|
4642
|
+
},
|
|
4643
|
+
evidence
|
|
4644
|
+
};
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4647
|
+
// src/engine/extractors/android-manifest.ts
|
|
4648
|
+
import { join as join10, relative as relative9 } from "path";
|
|
4649
|
+
var EXCLUDED_SEGMENTS = /* @__PURE__ */ new Set(["build", "test", "androidTest", "debug"]);
|
|
4650
|
+
function isExcludedManifestPath(relPath) {
|
|
4651
|
+
return relPath.split(/[\\/]/).some((segment) => EXCLUDED_SEGMENTS.has(segment));
|
|
4652
|
+
}
|
|
4653
|
+
function findMainManifest(rootDir) {
|
|
4654
|
+
const knownCandidates = [
|
|
4655
|
+
join10(rootDir, "app/src/main/AndroidManifest.xml"),
|
|
4656
|
+
join10(rootDir, "android/app/src/main/AndroidManifest.xml"),
|
|
4657
|
+
join10(rootDir, "src/main/AndroidManifest.xml")
|
|
4658
|
+
];
|
|
4659
|
+
for (const candidate of knownCandidates) {
|
|
4660
|
+
if (fileExists(candidate)) return candidate;
|
|
4661
|
+
}
|
|
4662
|
+
const found = findAllFiles(rootDir, "AndroidManifest.xml", 5).filter(
|
|
4663
|
+
(abs) => !isExcludedManifestPath(relative9(rootDir, abs))
|
|
4664
|
+
);
|
|
4665
|
+
if (found.length === 0) return null;
|
|
4666
|
+
return [...found].sort(
|
|
4667
|
+
(a, b) => relative9(rootDir, a).split(/[\\/]/).length - relative9(rootDir, b).split(/[\\/]/).length
|
|
4668
|
+
)[0];
|
|
4669
|
+
}
|
|
4670
|
+
function extractPermissions(content) {
|
|
4671
|
+
const permissions = /* @__PURE__ */ new Set();
|
|
4672
|
+
const tagPattern = /<uses-permission(?:-sdk-23)?\b[^>]*>/g;
|
|
4673
|
+
const nameAttrPattern = /android:name\s*=\s*"([^"]+)"/;
|
|
4674
|
+
for (const tagMatch of content.matchAll(tagPattern)) {
|
|
4675
|
+
const nameMatch = tagMatch[0].match(nameAttrPattern);
|
|
4676
|
+
if (nameMatch) permissions.add(nameMatch[1]);
|
|
4677
|
+
}
|
|
4678
|
+
return Array.from(permissions).sort();
|
|
4679
|
+
}
|
|
4680
|
+
function extractAndroidManifest(rootDir) {
|
|
4681
|
+
const evidence = {};
|
|
4682
|
+
const manifestPath = findMainManifest(rootDir);
|
|
4683
|
+
if (!manifestPath) {
|
|
4684
|
+
return {
|
|
4685
|
+
android: {
|
|
4686
|
+
manifest_found: false,
|
|
4687
|
+
permissions: [],
|
|
4688
|
+
exported_components: 0,
|
|
4689
|
+
queries_declared: false
|
|
4690
|
+
},
|
|
4691
|
+
evidence
|
|
4692
|
+
};
|
|
4693
|
+
}
|
|
4694
|
+
const content = readFileSafe(manifestPath);
|
|
4695
|
+
if (!content) {
|
|
4696
|
+
return {
|
|
4697
|
+
android: {
|
|
4698
|
+
manifest_found: false,
|
|
4699
|
+
permissions: [],
|
|
4700
|
+
exported_components: 0,
|
|
4701
|
+
queries_declared: false
|
|
4702
|
+
},
|
|
4703
|
+
evidence
|
|
4704
|
+
};
|
|
4705
|
+
}
|
|
4706
|
+
evidence["android.manifest"] = { file: relative9(rootDir, manifestPath) };
|
|
4707
|
+
const permissions = extractPermissions(content);
|
|
4708
|
+
const exportedComponents = (content.match(/android:exported\s*=\s*"true"/g) ?? []).length;
|
|
4709
|
+
const queriesDeclared = /<queries[\s>]/.test(content);
|
|
4710
|
+
return {
|
|
4711
|
+
android: {
|
|
4712
|
+
manifest_found: true,
|
|
4713
|
+
permissions,
|
|
4714
|
+
exported_components: exportedComponents,
|
|
4715
|
+
queries_declared: queriesDeclared
|
|
4716
|
+
},
|
|
4717
|
+
evidence
|
|
4718
|
+
};
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
// src/engine/extractors/gradle-targets.ts
|
|
4722
|
+
import { join as join11, relative as relative10 } from "path";
|
|
4723
|
+
function candidateGradleFiles(rootDir) {
|
|
4724
|
+
const candidates = [];
|
|
4725
|
+
const add = (p) => {
|
|
4726
|
+
if (p && !candidates.includes(p)) candidates.push(p);
|
|
4727
|
+
};
|
|
4728
|
+
add(findFile(join11(rootDir, "app"), "build.gradle", 2));
|
|
4729
|
+
add(findFile(join11(rootDir, "app"), "build.gradle.kts", 2));
|
|
4730
|
+
add(findFile(join11(rootDir, "android/app"), "build.gradle", 2));
|
|
4731
|
+
add(findFile(join11(rootDir, "android/app"), "build.gradle.kts", 2));
|
|
4732
|
+
add(findFile(rootDir, "build.gradle", 1));
|
|
4733
|
+
add(findFile(rootDir, "build.gradle.kts", 1));
|
|
4734
|
+
for (const f of findFiles(rootDir, ["build.gradle", "build.gradle.kts"], 4)) {
|
|
4735
|
+
add(f);
|
|
4736
|
+
}
|
|
4737
|
+
return candidates;
|
|
4738
|
+
}
|
|
4739
|
+
function extractIntSetting(content, name) {
|
|
4740
|
+
const pattern = new RegExp(`\\b${name}(?:Version)?\\s*(?:=)?\\s*(\\d+)`);
|
|
4741
|
+
const match = content.match(pattern);
|
|
4742
|
+
return match ? parseInt(match[1], 10) : null;
|
|
4743
|
+
}
|
|
4744
|
+
function extractGradleTargets(rootDir) {
|
|
4745
|
+
const evidence = {};
|
|
4746
|
+
let targetAndroidSdk = null;
|
|
4747
|
+
let compileSdk = null;
|
|
4748
|
+
let minAndroidSdk = null;
|
|
4749
|
+
let usedFile = null;
|
|
4750
|
+
for (const filePath of candidateGradleFiles(rootDir)) {
|
|
4751
|
+
const content = readFileSafe(filePath);
|
|
4752
|
+
if (!content) continue;
|
|
4753
|
+
const target = extractIntSetting(content, "targetSdk");
|
|
4754
|
+
const compile = extractIntSetting(content, "compileSdk");
|
|
4755
|
+
const min = extractIntSetting(content, "minSdk");
|
|
4756
|
+
if (target === null && compile === null && min === null) continue;
|
|
4757
|
+
if (targetAndroidSdk === null && target !== null) targetAndroidSdk = target;
|
|
4758
|
+
if (compileSdk === null && compile !== null) compileSdk = compile;
|
|
4759
|
+
if (minAndroidSdk === null && min !== null) minAndroidSdk = min;
|
|
4760
|
+
if (usedFile === null) usedFile = filePath;
|
|
4761
|
+
if (targetAndroidSdk !== null && compileSdk !== null && minAndroidSdk !== null) break;
|
|
4762
|
+
}
|
|
4763
|
+
if (usedFile) {
|
|
4764
|
+
evidence["android.gradle"] = { file: relative10(rootDir, usedFile) };
|
|
4765
|
+
}
|
|
4766
|
+
return {
|
|
4767
|
+
target_android_sdk: targetAndroidSdk,
|
|
4768
|
+
compile_sdk: compileSdk,
|
|
4769
|
+
min_android_sdk: minAndroidSdk,
|
|
4770
|
+
evidence
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
// src/engine/extractors/unity.ts
|
|
4775
|
+
import { join as join12 } from "path";
|
|
4776
|
+
import YAML3 from "yaml";
|
|
4777
|
+
var PROJECT_SETTINGS_REL = join12("ProjectSettings", "ProjectSettings.asset");
|
|
4778
|
+
function toPlainYaml(raw) {
|
|
4779
|
+
return raw.split("\n").filter((line) => !line.startsWith("%")).map((line) => /^---\s*!u!/.test(line) ? "---" : line).join("\n");
|
|
4780
|
+
}
|
|
4781
|
+
function regexFallbackAppIds(raw) {
|
|
4782
|
+
const idx = raw.indexOf("applicationIdentifier:");
|
|
4783
|
+
if (idx < 0) return { ios: null, android: null };
|
|
4784
|
+
const after = raw.slice(idx + "applicationIdentifier:".length);
|
|
4785
|
+
const nextSiblingKey = after.search(/\n {0,2}\S/);
|
|
4786
|
+
const block = nextSiblingKey >= 0 ? after.slice(0, nextSiblingKey) : after;
|
|
4787
|
+
const iphone = block.match(/iPhone:\s*(\S+)/);
|
|
4788
|
+
const android = block.match(/Android:\s*(\S+)/);
|
|
4789
|
+
return {
|
|
4790
|
+
ios: iphone ? iphone[1].trim() : null,
|
|
4791
|
+
android: android ? android[1].trim() : null
|
|
4792
|
+
};
|
|
4793
|
+
}
|
|
4794
|
+
function extractUnityProjectSettings(rootDir) {
|
|
4795
|
+
const filePath = join12(rootDir, "ProjectSettings", "ProjectSettings.asset");
|
|
4796
|
+
const raw = readFileSafe(filePath);
|
|
4797
|
+
if (raw === null) return null;
|
|
4798
|
+
const evidence = {
|
|
4799
|
+
"unity.project_settings": { file: PROJECT_SETTINGS_REL }
|
|
4800
|
+
};
|
|
4801
|
+
let ios = null;
|
|
4802
|
+
let android = null;
|
|
4803
|
+
let version = null;
|
|
4804
|
+
let productName = null;
|
|
4805
|
+
try {
|
|
4806
|
+
const parsed = YAML3.parse(toPlainYaml(raw));
|
|
4807
|
+
const player = parsed?.PlayerSettings;
|
|
4808
|
+
if (player && typeof player === "object") {
|
|
4809
|
+
const appId = player.applicationIdentifier;
|
|
4810
|
+
if (appId && typeof appId === "object") {
|
|
4811
|
+
ios = typeof appId.iPhone === "string" ? appId.iPhone : null;
|
|
4812
|
+
android = typeof appId.Android === "string" ? appId.Android : null;
|
|
4813
|
+
}
|
|
4814
|
+
version = typeof player.bundleVersion === "string" ? player.bundleVersion : null;
|
|
4815
|
+
productName = typeof player.productName === "string" ? player.productName : null;
|
|
4816
|
+
}
|
|
4817
|
+
} catch {
|
|
4818
|
+
const fallback = regexFallbackAppIds(raw);
|
|
4819
|
+
ios = fallback.ios;
|
|
4820
|
+
android = fallback.android;
|
|
4821
|
+
evidence["unity.project_settings"] = {
|
|
4822
|
+
file: PROJECT_SETTINGS_REL,
|
|
4823
|
+
detail: "regex fallback (malformed YAML)"
|
|
4824
|
+
};
|
|
4825
|
+
}
|
|
4826
|
+
return {
|
|
4827
|
+
bundle_id: ios ?? android ?? null,
|
|
4828
|
+
bundle_ids: { ios, android },
|
|
4829
|
+
app_version: version,
|
|
4830
|
+
product_name: productName,
|
|
4831
|
+
evidence
|
|
4832
|
+
};
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
// src/engine/extractors/kmp.ts
|
|
4836
|
+
import { readdirSync as readdirSync9, statSync as statSync7, existsSync as existsSync6 } from "fs";
|
|
4837
|
+
import { join as join13 } from "path";
|
|
4838
|
+
function containsXcodeproj(dir, maxDepth) {
|
|
4839
|
+
function walk(current, depth) {
|
|
4840
|
+
let entries;
|
|
4841
|
+
try {
|
|
4842
|
+
entries = readdirSync9(current);
|
|
4843
|
+
} catch {
|
|
4844
|
+
return false;
|
|
4845
|
+
}
|
|
4846
|
+
for (const entry of entries) {
|
|
4847
|
+
const full = join13(current, entry);
|
|
4848
|
+
let isDirectory = false;
|
|
4849
|
+
try {
|
|
4850
|
+
isDirectory = statSync7(full).isDirectory();
|
|
4851
|
+
} catch {
|
|
4852
|
+
continue;
|
|
4853
|
+
}
|
|
4854
|
+
if (!isDirectory) continue;
|
|
4855
|
+
if (entry.endsWith(".xcodeproj")) return true;
|
|
4856
|
+
if (depth < maxDepth && walk(full, depth + 1)) return true;
|
|
4857
|
+
}
|
|
4858
|
+
return false;
|
|
4859
|
+
}
|
|
4860
|
+
return walk(dir, 0);
|
|
4861
|
+
}
|
|
4862
|
+
var ANDROID_APPLICATION_MODULE_RE = /applicationId\b|com\.android\.application/;
|
|
4863
|
+
var KMP_PLUGIN_MARKER_RE2 = /kotlin\(\s*["']multiplatform["']\s*\)|id\(\s*["']org\.jetbrains\.kotlin\.multiplatform["']\s*\)|kotlinMultiplatform/;
|
|
4864
|
+
function readModuleGradle(moduleDir) {
|
|
4865
|
+
return readFileSafe(join13(moduleDir, "build.gradle.kts")) ?? readFileSafe(join13(moduleDir, "build.gradle"));
|
|
4866
|
+
}
|
|
4867
|
+
function findKmpLayout(rootDir) {
|
|
4868
|
+
let iosAppDir = null;
|
|
4869
|
+
const iosAppCandidate = join13(rootDir, "iosApp");
|
|
4870
|
+
if (existsSync6(iosAppCandidate)) {
|
|
4871
|
+
const hasXcodeproj = containsXcodeproj(iosAppCandidate, 3);
|
|
4872
|
+
const hasInfoPlist = findFile(iosAppCandidate, "Info.plist", 3) !== null;
|
|
4873
|
+
if (hasXcodeproj || hasInfoPlist) iosAppDir = iosAppCandidate;
|
|
4874
|
+
}
|
|
4875
|
+
let androidModuleDir = null;
|
|
4876
|
+
for (const name of ["androidApp", "composeApp", "app"]) {
|
|
4877
|
+
const candidate = join13(rootDir, name);
|
|
4878
|
+
const content = readModuleGradle(candidate);
|
|
4879
|
+
if (content && ANDROID_APPLICATION_MODULE_RE.test(content)) {
|
|
4880
|
+
androidModuleDir = candidate;
|
|
4881
|
+
break;
|
|
4882
|
+
}
|
|
4883
|
+
}
|
|
4884
|
+
let sharedDir = null;
|
|
4885
|
+
try {
|
|
4886
|
+
const entries = [...readdirSync9(rootDir)].sort();
|
|
4887
|
+
for (const entry of entries) {
|
|
4888
|
+
const candidate = join13(rootDir, entry);
|
|
4889
|
+
let isDirectory = false;
|
|
4890
|
+
try {
|
|
4891
|
+
isDirectory = statSync7(candidate).isDirectory();
|
|
4892
|
+
} catch {
|
|
4893
|
+
continue;
|
|
4894
|
+
}
|
|
4895
|
+
if (!isDirectory) continue;
|
|
4896
|
+
const content = readModuleGradle(candidate);
|
|
4897
|
+
if (content && KMP_PLUGIN_MARKER_RE2.test(content)) {
|
|
4898
|
+
sharedDir = candidate;
|
|
4899
|
+
break;
|
|
4900
|
+
}
|
|
4901
|
+
}
|
|
4902
|
+
} catch {
|
|
4903
|
+
}
|
|
4904
|
+
return { iosAppDir, androidModuleDir, sharedDir };
|
|
4905
|
+
}
|
|
4906
|
+
|
|
4907
|
+
// src/engine/profile-builder.ts
|
|
4908
|
+
var RESTORE_RE = /restorePurchases|restoreCompletedTransactions|restoreTransactions|queryPurchasesAsync|\brestore\s*\(\s*\)/;
|
|
4909
|
+
var ACCOUNT_DELETION_RE = /deleteAccount|removeAccount|deleteUser|closeAccount|account.{0,20}delet/i;
|
|
4910
|
+
var ACCOUNT_CREATION_RE = /createAccount|createUser|signUp|register(?:User|Account|WithEmail)/;
|
|
4911
|
+
var PRIVACY_POLICY_RE = /privacy[\s_-]?policy/i;
|
|
4912
|
+
var EXTERNAL_CHECKOUT_RE = /checkout\.stripe\.com|buy\.stripe\.com|paypal\.com|lemonsqueezy|gumroad|paddle\.com/i;
|
|
4913
|
+
var WEBVIEW_RE = /WKWebView|UIWebView|webview_flutter|react-native-webview|InAppWebView|android\.webkit\.WebView/;
|
|
4914
|
+
function round2(n) {
|
|
4915
|
+
return Math.round(n * 100) / 100;
|
|
4916
|
+
}
|
|
4917
|
+
function scanSourceSignals(input) {
|
|
4918
|
+
let restore = false;
|
|
4919
|
+
let deletion = false;
|
|
4920
|
+
let creation = false;
|
|
4921
|
+
let privacyPolicy = false;
|
|
4922
|
+
let externalCheckout = false;
|
|
4923
|
+
let webviewFiles = 0;
|
|
4924
|
+
let sourceChars = 0;
|
|
4925
|
+
for (const { content } of input.files) {
|
|
4926
|
+
sourceChars += content.length;
|
|
4927
|
+
if (!restore && RESTORE_RE.test(content)) restore = true;
|
|
4928
|
+
if (!deletion && ACCOUNT_DELETION_RE.test(content)) deletion = true;
|
|
4929
|
+
if (!creation && ACCOUNT_CREATION_RE.test(content)) creation = true;
|
|
4930
|
+
if (!privacyPolicy && PRIVACY_POLICY_RE.test(content)) privacyPolicy = true;
|
|
4931
|
+
if (!externalCheckout && EXTERNAL_CHECKOUT_RE.test(content)) externalCheckout = true;
|
|
4932
|
+
if (WEBVIEW_RE.test(content)) webviewFiles += 1;
|
|
4933
|
+
}
|
|
4934
|
+
const scannedFiles = input.files.length;
|
|
4935
|
+
return {
|
|
4936
|
+
restore_purchases_found: restore,
|
|
4937
|
+
account_deletion_found: deletion,
|
|
4938
|
+
privacy_policy_link_found: privacyPolicy,
|
|
4939
|
+
external_checkout_url_found: externalCheckout,
|
|
4940
|
+
webview_ratio: round2(webviewFiles / Math.max(scannedFiles, 1)),
|
|
4941
|
+
account_creation_found: creation,
|
|
4942
|
+
webview_files: webviewFiles,
|
|
4943
|
+
scanned_files: scannedFiles,
|
|
4944
|
+
source_chars: sourceChars
|
|
4945
|
+
};
|
|
4946
|
+
}
|
|
4947
|
+
function permissionShortName(permission) {
|
|
4948
|
+
const idx = permission.lastIndexOf(".");
|
|
4949
|
+
return idx >= 0 ? permission.slice(idx + 1) : permission;
|
|
4950
|
+
}
|
|
4951
|
+
function sdkOriginFile(coordinate, stack) {
|
|
4952
|
+
const ecosystem = coordinate.slice(0, coordinate.indexOf(":"));
|
|
4953
|
+
switch (ecosystem) {
|
|
4954
|
+
case "npm":
|
|
4955
|
+
return "package.json";
|
|
4956
|
+
case "pub":
|
|
4957
|
+
return "pubspec.yaml";
|
|
4958
|
+
case "pods":
|
|
4959
|
+
return stack === "swift" ? "Podfile" : "ios/Podfile";
|
|
4960
|
+
case "spm":
|
|
4961
|
+
return "Package.swift";
|
|
4962
|
+
case "gradle":
|
|
4963
|
+
return stack === "kotlin" ? "app/build.gradle" : "android/app/build.gradle";
|
|
4964
|
+
case "nuget":
|
|
4965
|
+
return "*.csproj";
|
|
4966
|
+
case "upm":
|
|
4967
|
+
return "Packages/manifest.json";
|
|
4968
|
+
default:
|
|
4969
|
+
return null;
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
function deriveCapabilities(input) {
|
|
4973
|
+
const caps = /* @__PURE__ */ new Set();
|
|
4974
|
+
const registryById = new Map(
|
|
4975
|
+
input.registry.map((entry) => [entry.id, entry])
|
|
4976
|
+
);
|
|
4977
|
+
for (const sdk of input.sdks) {
|
|
4978
|
+
const entry = registryById.get(sdk.id);
|
|
4979
|
+
for (const cap of entry?.implies ?? []) caps.add(cap);
|
|
4980
|
+
}
|
|
4981
|
+
const ent = input.entitlements?.entitlements;
|
|
4982
|
+
if (ent) {
|
|
4983
|
+
if (ent.sign_in_with_apple) caps.add("sign_in_with_apple");
|
|
4984
|
+
if (ent.aps_environment !== null) caps.add("push_notifications");
|
|
4985
|
+
if (ent.healthkit) caps.add("health");
|
|
4986
|
+
}
|
|
4987
|
+
if (input.privacyManifest?.privacy_manifest.tracking === true) {
|
|
4988
|
+
caps.add("tracking");
|
|
4989
|
+
}
|
|
4990
|
+
const signals = input.signals;
|
|
4991
|
+
if (signals) {
|
|
4992
|
+
if (signals.account_creation_found) caps.add("account_creation");
|
|
4993
|
+
if (signals.account_deletion_found) caps.add("account_deletion");
|
|
4994
|
+
if (signals.webview_ratio >= 0.5) caps.add("webview_heavy");
|
|
4995
|
+
if (signals.external_checkout_url_found) caps.add("external_payment");
|
|
4996
|
+
}
|
|
4997
|
+
for (const permission of input.android?.android.permissions ?? []) {
|
|
4998
|
+
switch (permissionShortName(permission)) {
|
|
4999
|
+
case "ACCESS_FINE_LOCATION":
|
|
5000
|
+
case "ACCESS_COARSE_LOCATION":
|
|
5001
|
+
caps.add("location");
|
|
5002
|
+
break;
|
|
5003
|
+
case "CAMERA":
|
|
5004
|
+
caps.add("camera");
|
|
5005
|
+
break;
|
|
5006
|
+
case "RECORD_AUDIO":
|
|
5007
|
+
caps.add("microphone");
|
|
5008
|
+
break;
|
|
5009
|
+
case "ACTIVITY_RECOGNITION":
|
|
5010
|
+
case "BODY_SENSORS":
|
|
5011
|
+
caps.add("health");
|
|
5012
|
+
break;
|
|
5013
|
+
case "READ_CONTACTS":
|
|
5014
|
+
caps.add("contacts");
|
|
5015
|
+
break;
|
|
5016
|
+
default:
|
|
5017
|
+
break;
|
|
5018
|
+
}
|
|
5019
|
+
}
|
|
5020
|
+
const usage = input.ios?.plist?.usage_descriptions;
|
|
5021
|
+
if (usage) {
|
|
5022
|
+
const has = (key) => Object.prototype.hasOwnProperty.call(usage, key);
|
|
5023
|
+
const keys = Object.keys(usage);
|
|
5024
|
+
if (has("NSCameraUsageDescription")) caps.add("camera");
|
|
5025
|
+
if (has("NSMicrophoneUsageDescription")) caps.add("microphone");
|
|
5026
|
+
if (keys.some((k) => k.startsWith("NSLocationWhenInUse") || k.startsWith("NSLocationAlways"))) {
|
|
5027
|
+
caps.add("location");
|
|
5028
|
+
}
|
|
5029
|
+
if (has("NSContactsUsageDescription")) caps.add("contacts");
|
|
5030
|
+
if (keys.some((k) => k.startsWith("NSPhotoLibrary"))) caps.add("photos");
|
|
5031
|
+
if (has("NSHealthShareUsageDescription") || has("NSHealthUpdateUsageDescription")) {
|
|
5032
|
+
caps.add("health");
|
|
5033
|
+
}
|
|
5034
|
+
if (keys.some((k) => k.startsWith("NSBluetooth"))) caps.add("bluetooth");
|
|
5035
|
+
if (has("NSUserTrackingUsageDescription")) caps.add("tracking");
|
|
5036
|
+
}
|
|
5037
|
+
return Array.from(caps).sort();
|
|
5038
|
+
}
|
|
5039
|
+
function mergeEvidence(input) {
|
|
5040
|
+
const evidence = {};
|
|
5041
|
+
const sources = [
|
|
5042
|
+
input.ios?.evidence,
|
|
5043
|
+
input.entitlements?.evidence,
|
|
5044
|
+
input.privacyManifest?.evidence,
|
|
5045
|
+
input.android?.evidence,
|
|
5046
|
+
input.gradleTargets?.evidence
|
|
5047
|
+
];
|
|
5048
|
+
for (const source of sources) {
|
|
5049
|
+
if (source) Object.assign(evidence, source);
|
|
5050
|
+
}
|
|
5051
|
+
const firstCoordinate = input.sdks[0]?.matched_coordinates[0];
|
|
5052
|
+
if (firstCoordinate) {
|
|
5053
|
+
const file = sdkOriginFile(firstCoordinate, input.stackResult.stack);
|
|
5054
|
+
if (file) evidence["sdks"] = { file, detail: "dependency manifest" };
|
|
5055
|
+
}
|
|
5056
|
+
return evidence;
|
|
5057
|
+
}
|
|
5058
|
+
function buildAppProfile(input) {
|
|
5059
|
+
const hasIos = input.ios !== null || input.entitlements !== null || input.privacyManifest !== null;
|
|
5060
|
+
const ios = hasIos ? {
|
|
5061
|
+
plist_source: input.ios?.plist_source ?? "none",
|
|
5062
|
+
plist: input.ios?.plist ?? null,
|
|
5063
|
+
entitlements: input.entitlements?.entitlements ?? null,
|
|
5064
|
+
privacy_manifest: input.privacyManifest?.privacy_manifest ?? null
|
|
5065
|
+
} : null;
|
|
5066
|
+
const android = input.android ? input.android.android : null;
|
|
5067
|
+
const signals = input.signals ? {
|
|
5068
|
+
restore_purchases_found: input.signals.restore_purchases_found,
|
|
5069
|
+
account_deletion_found: input.signals.account_deletion_found,
|
|
5070
|
+
privacy_policy_link_found: input.signals.privacy_policy_link_found,
|
|
5071
|
+
external_checkout_url_found: input.signals.external_checkout_url_found,
|
|
5072
|
+
webview_ratio: input.signals.webview_ratio
|
|
5073
|
+
} : {
|
|
5074
|
+
restore_purchases_found: null,
|
|
5075
|
+
account_deletion_found: null,
|
|
5076
|
+
privacy_policy_link_found: null,
|
|
5077
|
+
external_checkout_url_found: null,
|
|
5078
|
+
webview_ratio: null
|
|
5079
|
+
};
|
|
5080
|
+
return {
|
|
5081
|
+
schema_version: 1,
|
|
5082
|
+
generated_at: input.generatedAt,
|
|
5083
|
+
stack: input.stackResult.stack,
|
|
5084
|
+
stack_label: input.stackResult.label,
|
|
5085
|
+
platforms: input.stackResult.platforms,
|
|
5086
|
+
app: {
|
|
5087
|
+
name: input.appConfig.app_name,
|
|
5088
|
+
bundle_id: input.appConfig.bundle_id,
|
|
5089
|
+
version: input.appConfig.version ?? null,
|
|
5090
|
+
min_ios_version: input.appConfig.min_ios_version ?? null,
|
|
5091
|
+
min_android_sdk: input.gradleTargets?.min_android_sdk ?? null,
|
|
5092
|
+
target_android_sdk: input.gradleTargets?.target_android_sdk ?? null
|
|
5093
|
+
},
|
|
5094
|
+
sdks: input.sdks,
|
|
5095
|
+
raw_dependencies: input.deps,
|
|
5096
|
+
ios,
|
|
5097
|
+
android,
|
|
5098
|
+
capabilities: deriveCapabilities(input),
|
|
5099
|
+
signals,
|
|
5100
|
+
evidence: mergeEvidence(input),
|
|
5101
|
+
stats: {
|
|
5102
|
+
files_scanned: input.signals?.scanned_files ?? 0,
|
|
5103
|
+
source_chars_read: input.signals?.source_chars ?? 0
|
|
5104
|
+
}
|
|
5105
|
+
};
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
// src/engine/rules.ts
|
|
5109
|
+
function resolveFact(profile, path2) {
|
|
5110
|
+
const segments = path2.split(".");
|
|
5111
|
+
let current = profile;
|
|
5112
|
+
for (let i = 0; i < segments.length; i++) {
|
|
5113
|
+
if (current === null || current === void 0) return { kind: "unknown" };
|
|
5114
|
+
if (typeof current !== "object") return { kind: "unknown" };
|
|
5115
|
+
const obj = current;
|
|
5116
|
+
const seg = segments[i];
|
|
5117
|
+
const isLeaf = i === segments.length - 1;
|
|
5118
|
+
if (isLeaf) {
|
|
5119
|
+
if (!Object.prototype.hasOwnProperty.call(obj, seg)) return { kind: "missing" };
|
|
5120
|
+
const value = obj[seg];
|
|
5121
|
+
if (value === null || value === void 0) return { kind: "nullvalue" };
|
|
5122
|
+
return { kind: "value", value };
|
|
5123
|
+
}
|
|
5124
|
+
current = obj[seg];
|
|
5125
|
+
}
|
|
5126
|
+
return { kind: "unknown" };
|
|
5127
|
+
}
|
|
5128
|
+
function strictEquals(a, b) {
|
|
5129
|
+
return a === b;
|
|
5130
|
+
}
|
|
5131
|
+
function valueIncludes(collection, needle) {
|
|
5132
|
+
if (Array.isArray(collection)) return collection.includes(needle);
|
|
5133
|
+
if (typeof collection === "string") return collection.includes(String(needle));
|
|
5134
|
+
return false;
|
|
5135
|
+
}
|
|
5136
|
+
function evaluateFactPredicate(profile, fact, op, value) {
|
|
5137
|
+
const r = resolveFact(profile, fact);
|
|
5138
|
+
if (r.kind === "unknown") return "unknown";
|
|
5139
|
+
switch (op) {
|
|
5140
|
+
case "exists":
|
|
5141
|
+
if (r.kind === "value") return "true";
|
|
5142
|
+
if (r.kind === "nullvalue") return "unknown";
|
|
5143
|
+
return "false";
|
|
5144
|
+
// missing
|
|
5145
|
+
case "absent":
|
|
5146
|
+
if (r.kind === "value") return "false";
|
|
5147
|
+
if (r.kind === "nullvalue") return "unknown";
|
|
5148
|
+
return "true";
|
|
5149
|
+
// missing
|
|
5150
|
+
case "eq":
|
|
5151
|
+
if (r.kind === "value") return strictEquals(r.value, value) ? "true" : "false";
|
|
5152
|
+
return "false";
|
|
5153
|
+
case "neq":
|
|
5154
|
+
if (r.kind === "value") return strictEquals(r.value, value) ? "false" : "true";
|
|
5155
|
+
return "true";
|
|
5156
|
+
case "gte":
|
|
5157
|
+
if (r.kind === "value" && typeof r.value === "number" && typeof value === "number") {
|
|
5158
|
+
return r.value >= value ? "true" : "false";
|
|
5159
|
+
}
|
|
5160
|
+
return "unknown";
|
|
5161
|
+
// non-numeric VALUE, or NULLVALUE/MISSING
|
|
5162
|
+
case "lte":
|
|
5163
|
+
if (r.kind === "value" && typeof r.value === "number" && typeof value === "number") {
|
|
5164
|
+
return r.value <= value ? "true" : "false";
|
|
5165
|
+
}
|
|
5166
|
+
return "unknown";
|
|
5167
|
+
case "includes":
|
|
5168
|
+
if (r.kind === "value") return valueIncludes(r.value, value) ? "true" : "false";
|
|
5169
|
+
return "unknown";
|
|
5170
|
+
case "not_includes":
|
|
5171
|
+
if (r.kind === "value") return valueIncludes(r.value, value) ? "false" : "true";
|
|
5172
|
+
return "unknown";
|
|
5173
|
+
case "matches":
|
|
5174
|
+
if (r.kind === "value") {
|
|
5175
|
+
if (typeof r.value !== "string") return "unknown";
|
|
5176
|
+
let re;
|
|
5177
|
+
try {
|
|
5178
|
+
re = new RegExp(String(value));
|
|
5179
|
+
} catch {
|
|
5180
|
+
return "unknown";
|
|
5181
|
+
}
|
|
5182
|
+
return re.test(r.value) ? "true" : "false";
|
|
5183
|
+
}
|
|
5184
|
+
return "unknown";
|
|
5185
|
+
// nullvalue/missing
|
|
5186
|
+
default:
|
|
5187
|
+
return "unknown";
|
|
5188
|
+
}
|
|
5189
|
+
}
|
|
5190
|
+
function evaluateCondition(profile, condition, matchedSdkIds) {
|
|
5191
|
+
if ("all" in condition) {
|
|
5192
|
+
let sawUnknown = false;
|
|
5193
|
+
for (const child of condition.all) {
|
|
5194
|
+
const t = evaluateCondition(profile, child, matchedSdkIds);
|
|
5195
|
+
if (t === "false") return "false";
|
|
5196
|
+
if (t === "unknown") sawUnknown = true;
|
|
5197
|
+
}
|
|
5198
|
+
return sawUnknown ? "unknown" : "true";
|
|
5199
|
+
}
|
|
5200
|
+
if ("any" in condition) {
|
|
5201
|
+
let sawUnknown = false;
|
|
5202
|
+
for (const child of condition.any) {
|
|
5203
|
+
const t = evaluateCondition(profile, child, matchedSdkIds);
|
|
5204
|
+
if (t === "true") return "true";
|
|
5205
|
+
if (t === "unknown") sawUnknown = true;
|
|
5206
|
+
}
|
|
5207
|
+
return sawUnknown ? "unknown" : "false";
|
|
5208
|
+
}
|
|
5209
|
+
if ("not" in condition) {
|
|
5210
|
+
const t = evaluateCondition(profile, condition.not, matchedSdkIds);
|
|
5211
|
+
if (t === "unknown") return "unknown";
|
|
5212
|
+
return t === "true" ? "false" : "true";
|
|
5213
|
+
}
|
|
5214
|
+
return evaluatePredicate(profile, condition, matchedSdkIds);
|
|
5215
|
+
}
|
|
5216
|
+
function evaluatePredicate(profile, pred, matchedSdkIds) {
|
|
5217
|
+
if ("always" in pred) return "true";
|
|
5218
|
+
if ("sdk" in pred) {
|
|
5219
|
+
const hit = profile.sdks.some((s) => s.id === pred.sdk);
|
|
5220
|
+
if (hit && matchedSdkIds) matchedSdkIds.add(pred.sdk);
|
|
5221
|
+
return hit ? "true" : "false";
|
|
5222
|
+
}
|
|
5223
|
+
if ("sdk_category" in pred) {
|
|
5224
|
+
let hit = false;
|
|
5225
|
+
for (const s of profile.sdks) {
|
|
5226
|
+
if (s.category === pred.sdk_category) {
|
|
5227
|
+
hit = true;
|
|
5228
|
+
if (matchedSdkIds) matchedSdkIds.add(s.id);
|
|
5229
|
+
}
|
|
5230
|
+
}
|
|
5231
|
+
return hit ? "true" : "false";
|
|
5232
|
+
}
|
|
5233
|
+
if ("sdk_flag" in pred) {
|
|
5234
|
+
let hit = false;
|
|
5235
|
+
for (const s of profile.sdks) {
|
|
5236
|
+
if (s.flags[pred.sdk_flag] === true) {
|
|
5237
|
+
hit = true;
|
|
5238
|
+
if (matchedSdkIds) matchedSdkIds.add(s.id);
|
|
5239
|
+
}
|
|
5240
|
+
}
|
|
5241
|
+
return hit ? "true" : "false";
|
|
5242
|
+
}
|
|
5243
|
+
if ("capability" in pred) {
|
|
5244
|
+
return profile.capabilities.includes(pred.capability) ? "true" : "false";
|
|
5245
|
+
}
|
|
5246
|
+
if ("platform" in pred) {
|
|
5247
|
+
return profile.platforms.includes(pred.platform) ? "true" : "false";
|
|
5248
|
+
}
|
|
5249
|
+
return evaluateFactPredicate(profile, pred.fact, pred.op, pred.value);
|
|
5250
|
+
}
|
|
5251
|
+
function interpolate(text, ctx) {
|
|
5252
|
+
return text.replace(
|
|
5253
|
+
/\{\{(\w+)\}\}/g,
|
|
5254
|
+
(match, key) => Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : match
|
|
5255
|
+
);
|
|
5256
|
+
}
|
|
5257
|
+
function formatFactValue(value) {
|
|
5258
|
+
if (Array.isArray(value)) {
|
|
5259
|
+
return value.map(
|
|
5260
|
+
(el) => el && typeof el === "object" && "name" in el ? String(el.name) : String(el)
|
|
5261
|
+
).join(", ");
|
|
5262
|
+
}
|
|
5263
|
+
if (value && typeof value === "object") return JSON.stringify(value);
|
|
5264
|
+
return String(value);
|
|
5265
|
+
}
|
|
5266
|
+
function buildEvidence(profile, facts) {
|
|
5267
|
+
const parts = [];
|
|
5268
|
+
for (const fact of facts) {
|
|
5269
|
+
const r = resolveFact(profile, fact);
|
|
5270
|
+
if (r.kind !== "value") continue;
|
|
5271
|
+
const formatted = formatFactValue(r.value);
|
|
5272
|
+
if (formatted === "") continue;
|
|
5273
|
+
parts.push(`${fact}: ${formatted}`);
|
|
5274
|
+
}
|
|
5275
|
+
return parts.join("; ");
|
|
5276
|
+
}
|
|
5277
|
+
function firstEvidenceFile(profile, facts) {
|
|
5278
|
+
for (const fact of facts) {
|
|
5279
|
+
const entry = profile.evidence[fact];
|
|
5280
|
+
if (entry?.file) return entry.file;
|
|
5281
|
+
}
|
|
5282
|
+
return void 0;
|
|
5283
|
+
}
|
|
5284
|
+
function expandTemplateRule(templateRule, profile, bundle) {
|
|
5285
|
+
const registryById = new Map(
|
|
5286
|
+
bundle.sdk_registry.map((entry) => [entry.id, entry])
|
|
5287
|
+
);
|
|
5288
|
+
const synthesized = [];
|
|
5289
|
+
for (const profileSdk of profile.sdks) {
|
|
5290
|
+
const entry = registryById.get(profileSdk.id);
|
|
5291
|
+
const requiredKeys = entry?.requires?.ios_plist_keys;
|
|
5292
|
+
if (!requiredKeys) continue;
|
|
5293
|
+
for (const req of requiredKeys) {
|
|
5294
|
+
if (!req.key.endsWith("UsageDescription")) continue;
|
|
5295
|
+
const guidelineName = bundle.guidelines[req.guideline]?.name ?? templateRule.guideline.name;
|
|
5296
|
+
const ctx = {
|
|
5297
|
+
plist_key: req.key,
|
|
5298
|
+
reason: req.reason,
|
|
5299
|
+
sdk_names: profileSdk.name
|
|
5300
|
+
};
|
|
5301
|
+
synthesized.push({
|
|
5302
|
+
id: `appstore.sdk-plist.${profileSdk.id}.${req.key}`,
|
|
5303
|
+
store: templateRule.store,
|
|
5304
|
+
guideline: { number: req.guideline, name: guidelineName },
|
|
5305
|
+
severity: templateRule.severity,
|
|
5306
|
+
category: templateRule.category,
|
|
5307
|
+
title: interpolate(templateRule.title, ctx),
|
|
5308
|
+
description: interpolate(templateRule.description, ctx),
|
|
5309
|
+
user_impact: interpolate(templateRule.user_impact, ctx),
|
|
5310
|
+
fix: interpolate(templateRule.fix, ctx),
|
|
5311
|
+
tags: templateRule.tags,
|
|
5312
|
+
applies_if: { all: [{ platform: "ios" }, { sdk: profileSdk.id }] },
|
|
5313
|
+
unverified_if: { fact: "ios.plist", op: "absent" },
|
|
5314
|
+
fail_if: { fact: `ios.plist.usage_descriptions.${req.key}`, op: "absent" },
|
|
5315
|
+
evidence_facts: templateRule.evidence_facts
|
|
5316
|
+
});
|
|
5317
|
+
}
|
|
5318
|
+
}
|
|
5319
|
+
return synthesized;
|
|
5320
|
+
}
|
|
5321
|
+
function evaluateRule(profile, rule) {
|
|
5322
|
+
const matchedSdkIds = /* @__PURE__ */ new Set();
|
|
5323
|
+
const appliesTri = rule.applies_if ? evaluateCondition(profile, rule.applies_if, matchedSdkIds) : "true";
|
|
5324
|
+
let status;
|
|
5325
|
+
let isFail = false;
|
|
5326
|
+
if (appliesTri === "false") {
|
|
5327
|
+
status = "na";
|
|
5328
|
+
} else if (appliesTri === "unknown") {
|
|
5329
|
+
status = "unverified";
|
|
5330
|
+
} else {
|
|
5331
|
+
const unverifiedTri = rule.unverified_if ? evaluateCondition(profile, rule.unverified_if, matchedSdkIds) : "false";
|
|
5332
|
+
if (unverifiedTri === "true" || unverifiedTri === "unknown") {
|
|
5333
|
+
status = "unverified";
|
|
5334
|
+
} else {
|
|
5335
|
+
const failTri = evaluateCondition(profile, rule.fail_if, matchedSdkIds);
|
|
5336
|
+
if (failTri === "true") {
|
|
5337
|
+
status = "fail";
|
|
5338
|
+
isFail = true;
|
|
5339
|
+
} else if (failTri === "unknown") {
|
|
5340
|
+
status = "unverified";
|
|
5341
|
+
} else {
|
|
5342
|
+
status = "pass";
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
}
|
|
5346
|
+
const sdkNames = profile.sdks.filter((s) => matchedSdkIds.has(s.id)).map((s) => s.name);
|
|
5347
|
+
const sdkNamesStr = sdkNames.length > 0 ? sdkNames.join(", ") : "the detected SDKs";
|
|
5348
|
+
const evidenceStr = buildEvidence(profile, rule.evidence_facts);
|
|
5349
|
+
const ctx = { sdk_names: sdkNamesStr, evidence: evidenceStr };
|
|
5350
|
+
const title = interpolate(rule.title, ctx);
|
|
5351
|
+
const check = {
|
|
5352
|
+
rule_id: rule.id,
|
|
5353
|
+
title,
|
|
5354
|
+
status,
|
|
5355
|
+
severity: rule.severity,
|
|
5356
|
+
guideline_number: rule.guideline.number
|
|
5357
|
+
};
|
|
5358
|
+
if (evidenceStr) check.evidence = evidenceStr;
|
|
5359
|
+
let finding;
|
|
5360
|
+
if (isFail) {
|
|
5361
|
+
check.finding_id = rule.id;
|
|
5362
|
+
finding = {
|
|
5363
|
+
id: rule.id,
|
|
5364
|
+
title,
|
|
5365
|
+
risk_level: rule.severity,
|
|
5366
|
+
category: rule.category,
|
|
5367
|
+
source: "codebase",
|
|
5368
|
+
detection: "static",
|
|
5369
|
+
guideline_number: rule.guideline.number,
|
|
5370
|
+
guideline_name: rule.guideline.name,
|
|
5371
|
+
description: interpolate(rule.description, ctx),
|
|
5372
|
+
user_impact: interpolate(rule.user_impact, ctx),
|
|
5373
|
+
evidence: evidenceStr,
|
|
5374
|
+
recommendation: interpolate(rule.fix, ctx),
|
|
5375
|
+
rule_id: rule.id
|
|
5376
|
+
};
|
|
5377
|
+
const file = firstEvidenceFile(profile, rule.evidence_facts);
|
|
5378
|
+
if (file) finding.file = file;
|
|
5379
|
+
}
|
|
5380
|
+
return { check, finding };
|
|
5381
|
+
}
|
|
5382
|
+
var STATUS_ORDER = {
|
|
5383
|
+
fail: 0,
|
|
5384
|
+
unverified: 1,
|
|
5385
|
+
pass: 2,
|
|
5386
|
+
na: 3
|
|
5387
|
+
};
|
|
5388
|
+
var SEVERITY_ORDER = {
|
|
5389
|
+
high: 0,
|
|
5390
|
+
medium: 1,
|
|
5391
|
+
low: 2,
|
|
5392
|
+
info: 3
|
|
5393
|
+
};
|
|
5394
|
+
function compareIds(a, b) {
|
|
5395
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
5396
|
+
}
|
|
5397
|
+
function evaluateRules(profile, bundle) {
|
|
5398
|
+
const effectiveRules = [];
|
|
5399
|
+
for (const rule of bundle.rules) {
|
|
5400
|
+
if (rule.template === "sdk_required_plist_keys") {
|
|
5401
|
+
effectiveRules.push(...expandTemplateRule(rule, profile, bundle));
|
|
5402
|
+
} else if (rule.template) {
|
|
5403
|
+
continue;
|
|
5404
|
+
} else {
|
|
5405
|
+
effectiveRules.push(rule);
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
5408
|
+
const checks = [];
|
|
5409
|
+
const findings = [];
|
|
5410
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
5411
|
+
for (const rule of effectiveRules) {
|
|
5412
|
+
if (seenIds.has(rule.id)) continue;
|
|
5413
|
+
seenIds.add(rule.id);
|
|
5414
|
+
const { check, finding } = evaluateRule(profile, rule);
|
|
5415
|
+
checks.push(check);
|
|
5416
|
+
if (finding) findings.push(finding);
|
|
5417
|
+
}
|
|
5418
|
+
checks.sort(
|
|
5419
|
+
(a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || compareIds(a.rule_id, b.rule_id)
|
|
5420
|
+
);
|
|
5421
|
+
findings.sort(
|
|
5422
|
+
(a, b) => SEVERITY_ORDER[a.risk_level] - SEVERITY_ORDER[b.risk_level] || compareIds(a.rule_id ?? a.id, b.rule_id ?? b.id)
|
|
5423
|
+
);
|
|
5424
|
+
return { checks, findings };
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
// src/engine/index.ts
|
|
5428
|
+
function extensionsForStack(stack) {
|
|
5429
|
+
switch (stack) {
|
|
5430
|
+
case "flutter":
|
|
5431
|
+
return [".dart"];
|
|
5432
|
+
case "swift":
|
|
5433
|
+
return [".swift", ".m", ".mm", ".h"];
|
|
5434
|
+
case "kotlin":
|
|
5435
|
+
return [".kt", ".kts"];
|
|
5436
|
+
case "expo":
|
|
5437
|
+
case "react-native":
|
|
5438
|
+
case "capacitor":
|
|
5439
|
+
return [".ts", ".tsx", ".js", ".jsx"];
|
|
5440
|
+
case "dotnet-maui":
|
|
5441
|
+
return [".cs", ".xaml"];
|
|
5442
|
+
case "unity":
|
|
5443
|
+
return [".cs"];
|
|
5444
|
+
case "kmp":
|
|
5445
|
+
return [".kt", ".kts", ".swift"];
|
|
5446
|
+
default:
|
|
5447
|
+
return [".ts", ".js", ".swift", ".dart", ".kt"];
|
|
5448
|
+
}
|
|
5449
|
+
}
|
|
5450
|
+
var SELF_SCAN_MAX_FILES = 300;
|
|
5451
|
+
var SELF_SCAN_MAX_TOTAL_CHARS = 200 * 1024;
|
|
5452
|
+
function selfScanSource(rootDir, stack) {
|
|
5453
|
+
const relPaths = scanDirectory(rootDir, {
|
|
5454
|
+
extensions: extensionsForStack(stack),
|
|
5455
|
+
maxDepth: 8,
|
|
5456
|
+
maxFiles: SELF_SCAN_MAX_FILES
|
|
5457
|
+
});
|
|
5458
|
+
const files = [];
|
|
5459
|
+
let totalChars = 0;
|
|
5460
|
+
for (const rel of relPaths) {
|
|
5461
|
+
if (files.length >= SELF_SCAN_MAX_FILES || totalChars >= SELF_SCAN_MAX_TOTAL_CHARS) break;
|
|
5462
|
+
const content = readFileSafe(join14(rootDir, rel));
|
|
5463
|
+
if (content === null) continue;
|
|
5464
|
+
files.push({ path: rel, content });
|
|
5465
|
+
totalChars += content.length;
|
|
5466
|
+
}
|
|
5467
|
+
return files;
|
|
5468
|
+
}
|
|
5469
|
+
function runStaticEngine(input) {
|
|
5470
|
+
const { rootDir, stackResult, bundle } = input;
|
|
5471
|
+
const { stack, platforms } = stackResult;
|
|
5472
|
+
const deps = collectRawDependencies(rootDir, stack);
|
|
5473
|
+
const sdks = matchSDKs(deps, bundle.sdk_registry);
|
|
5474
|
+
const isIos = platforms.includes("ios");
|
|
5475
|
+
const isAndroid = platforms.includes("android");
|
|
5476
|
+
const kmpLayout = stack === "kmp" ? findKmpLayout(rootDir) : null;
|
|
5477
|
+
const iosRoot = kmpLayout?.iosAppDir ?? rootDir;
|
|
5478
|
+
const androidRoot = kmpLayout?.androidModuleDir ?? rootDir;
|
|
5479
|
+
const ios = isIos ? extractIosPlist(iosRoot) : null;
|
|
5480
|
+
const entitlements = isIos ? extractIosEntitlements(iosRoot) : null;
|
|
5481
|
+
const privacyManifest = isIos ? extractIosPrivacyManifest(iosRoot) : null;
|
|
5482
|
+
const android = isAndroid ? extractAndroidManifest(androidRoot) : null;
|
|
5483
|
+
const gradleTargets = isAndroid ? extractGradleTargets(androidRoot) : null;
|
|
5484
|
+
let appConfig = input.appConfig;
|
|
5485
|
+
let extraEvidence = null;
|
|
5486
|
+
if (stack === "unity") {
|
|
5487
|
+
const unityExtraction = extractUnityProjectSettings(rootDir);
|
|
5488
|
+
if (unityExtraction) {
|
|
5489
|
+
appConfig = {
|
|
5490
|
+
...appConfig,
|
|
5491
|
+
app_name: appConfig.app_name ?? unityExtraction.product_name,
|
|
5492
|
+
bundle_id: appConfig.bundle_id ?? unityExtraction.bundle_id,
|
|
5493
|
+
version: appConfig.version ?? unityExtraction.app_version
|
|
5494
|
+
};
|
|
5495
|
+
extraEvidence = unityExtraction.evidence;
|
|
5496
|
+
}
|
|
5497
|
+
}
|
|
5498
|
+
let signals;
|
|
5499
|
+
if (input.sourceFiles !== void 0) {
|
|
5500
|
+
signals = scanSourceSignals({ files: input.sourceFiles });
|
|
5501
|
+
} else {
|
|
5502
|
+
const selfFiles = selfScanSource(rootDir, stack);
|
|
5503
|
+
signals = selfFiles.length > 0 ? scanSourceSignals({ files: selfFiles }) : null;
|
|
5504
|
+
}
|
|
5505
|
+
const profile = buildAppProfile({
|
|
5506
|
+
rootDir,
|
|
5507
|
+
stackResult,
|
|
5508
|
+
appConfig,
|
|
5509
|
+
deps,
|
|
5510
|
+
sdks,
|
|
5511
|
+
registry: bundle.sdk_registry,
|
|
5512
|
+
ios,
|
|
5513
|
+
entitlements,
|
|
5514
|
+
privacyManifest,
|
|
5515
|
+
android,
|
|
5516
|
+
gradleTargets,
|
|
5517
|
+
signals,
|
|
5518
|
+
generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
5519
|
+
});
|
|
5520
|
+
if (extraEvidence) {
|
|
5521
|
+
profile.evidence = { ...profile.evidence, ...extraEvidence };
|
|
5522
|
+
}
|
|
5523
|
+
const { checks, findings } = evaluateRules(profile, bundle);
|
|
5524
|
+
return { profile, checks, findings };
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
// src/engine/remote.ts
|
|
5528
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
5529
|
+
import { readFile, writeFile, mkdir, rm } from "fs/promises";
|
|
5530
|
+
import os from "os";
|
|
5531
|
+
import path from "path";
|
|
5532
|
+
import { fileURLToPath } from "url";
|
|
5533
|
+
var DEFAULT_BASE_URL = "https://forvibe.app";
|
|
5534
|
+
var DEFAULT_TIMEOUT_MS = 2500;
|
|
5535
|
+
var ENDPOINT_PATH = "/api/v1/review-rulepack?store=appstore";
|
|
5536
|
+
var BUNDLE_FILENAME = "bundle.appstore.json";
|
|
5537
|
+
var CACHE_BUNDLE_FILENAME = "rulepack-appstore.json";
|
|
5538
|
+
var CACHE_META_FILENAME = "rulepack-appstore.meta.json";
|
|
5539
|
+
async function loadRulepackBundle(opts = {}) {
|
|
5540
|
+
const baseUrl = opts.baseUrl ?? DEFAULT_BASE_URL;
|
|
5541
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
5542
|
+
const cacheDir = opts.cacheDir ?? path.join(os.homedir(), ".forvibe", "cache");
|
|
5543
|
+
if (!opts.disableRemote) {
|
|
5544
|
+
const meta = await readCacheMeta(cacheDir);
|
|
5545
|
+
const headers = {};
|
|
5546
|
+
if (meta?.etag) headers["If-None-Match"] = meta.etag;
|
|
5547
|
+
const url = `${baseUrl.replace(/\/+$/, "")}${ENDPOINT_PATH}`;
|
|
5548
|
+
const outcome = await tryFetch(url, headers, timeoutMs);
|
|
5549
|
+
if (outcome.kind === "fresh") {
|
|
5550
|
+
await writeCache(cacheDir, outcome.bundle, outcome.etag);
|
|
5551
|
+
return { bundle: outcome.bundle, source: "remote", version: outcome.bundle.version };
|
|
5552
|
+
}
|
|
5553
|
+
if (outcome.kind === "not-modified") {
|
|
5554
|
+
const cached = await readCachedBundle(cacheDir);
|
|
5555
|
+
if (cached) {
|
|
5556
|
+
return { bundle: cached, source: "cache", version: cached.version };
|
|
5557
|
+
}
|
|
5558
|
+
await deleteStaleMeta(cacheDir);
|
|
5559
|
+
return loadFromCacheOrBundled(cacheDir);
|
|
5560
|
+
}
|
|
5561
|
+
}
|
|
5562
|
+
return loadFromCacheOrBundled(cacheDir);
|
|
5563
|
+
}
|
|
5564
|
+
async function loadFromCacheOrBundled(cacheDir) {
|
|
5565
|
+
const cached = await readCachedBundle(cacheDir);
|
|
5566
|
+
if (cached) {
|
|
5567
|
+
return { bundle: cached, source: "cache", version: cached.version };
|
|
5568
|
+
}
|
|
5569
|
+
const bundled = loadBundledSnapshot();
|
|
5570
|
+
return { bundle: bundled, source: "bundled", version: bundled.version };
|
|
5571
|
+
}
|
|
5572
|
+
async function tryFetch(url, headers, timeoutMs) {
|
|
5573
|
+
let res;
|
|
5574
|
+
try {
|
|
5575
|
+
res = await fetchWithTimeout(url, headers, timeoutMs);
|
|
5576
|
+
} catch {
|
|
5577
|
+
return { kind: "failed" };
|
|
5578
|
+
}
|
|
5579
|
+
if (res.status === 304) {
|
|
5580
|
+
return { kind: "not-modified" };
|
|
5581
|
+
}
|
|
5582
|
+
if (!res.ok) {
|
|
5583
|
+
return { kind: "failed" };
|
|
5584
|
+
}
|
|
5585
|
+
let bundle;
|
|
5586
|
+
try {
|
|
5587
|
+
bundle = await res.json();
|
|
5588
|
+
} catch {
|
|
5589
|
+
return { kind: "failed" };
|
|
5590
|
+
}
|
|
5591
|
+
if (bundle?.schema_version !== 1) {
|
|
5592
|
+
return { kind: "failed" };
|
|
5593
|
+
}
|
|
5594
|
+
return { kind: "fresh", bundle, etag: res.headers.get("etag") ?? void 0 };
|
|
5595
|
+
}
|
|
5596
|
+
async function fetchWithTimeout(url, headers, timeoutMs) {
|
|
5597
|
+
const controller = new AbortController();
|
|
5598
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
5599
|
+
const fetchPromise = fetch(url, { headers, signal: controller.signal });
|
|
5600
|
+
fetchPromise.catch(() => {
|
|
5601
|
+
});
|
|
5602
|
+
try {
|
|
5603
|
+
return await Promise.race([
|
|
5604
|
+
fetchPromise,
|
|
5605
|
+
new Promise((_resolve, reject) => {
|
|
5606
|
+
controller.signal.addEventListener(
|
|
5607
|
+
"abort",
|
|
5608
|
+
() => reject(new Error(`rulepack fetch timed out after ${timeoutMs}ms`)),
|
|
5609
|
+
{ once: true }
|
|
5610
|
+
);
|
|
5611
|
+
})
|
|
5612
|
+
]);
|
|
5613
|
+
} finally {
|
|
5614
|
+
clearTimeout(timer);
|
|
5615
|
+
}
|
|
5616
|
+
}
|
|
5617
|
+
async function readCacheMeta(cacheDir) {
|
|
5618
|
+
try {
|
|
5619
|
+
const raw = await readFile(path.join(cacheDir, CACHE_META_FILENAME), "utf-8");
|
|
5620
|
+
const parsed = JSON.parse(raw);
|
|
5621
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
5622
|
+
} catch {
|
|
5623
|
+
return null;
|
|
5624
|
+
}
|
|
5625
|
+
}
|
|
5626
|
+
async function readCachedBundle(cacheDir) {
|
|
5627
|
+
try {
|
|
5628
|
+
const raw = await readFile(path.join(cacheDir, CACHE_BUNDLE_FILENAME), "utf-8");
|
|
5629
|
+
const parsed = JSON.parse(raw);
|
|
5630
|
+
return parsed && parsed.schema_version === 1 ? parsed : null;
|
|
5631
|
+
} catch {
|
|
5632
|
+
return null;
|
|
5633
|
+
}
|
|
5634
|
+
}
|
|
5635
|
+
async function deleteStaleMeta(cacheDir) {
|
|
5636
|
+
try {
|
|
5637
|
+
await rm(path.join(cacheDir, CACHE_META_FILENAME), { force: true });
|
|
5638
|
+
} catch {
|
|
5639
|
+
}
|
|
5640
|
+
}
|
|
5641
|
+
async function writeCache(cacheDir, bundle, etag) {
|
|
5642
|
+
try {
|
|
5643
|
+
await mkdir(cacheDir, { recursive: true });
|
|
5644
|
+
await writeFile(path.join(cacheDir, CACHE_BUNDLE_FILENAME), JSON.stringify(bundle, null, 2), "utf-8");
|
|
5645
|
+
const meta = { fetched_at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
5646
|
+
if (etag) meta.etag = etag;
|
|
5647
|
+
await writeFile(path.join(cacheDir, CACHE_META_FILENAME), JSON.stringify(meta, null, 2), "utf-8");
|
|
5648
|
+
} catch {
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
function getEngineDataDir() {
|
|
5652
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5653
|
+
const __dirname = path.dirname(__filename);
|
|
5654
|
+
const candidates = [
|
|
5655
|
+
path.join(__dirname, "data"),
|
|
5656
|
+
// dev, running from source (src/engine/data)
|
|
5657
|
+
path.join(__dirname, "engine", "data"),
|
|
5658
|
+
// bundled, __dirname = dist/ -> dist/engine/data
|
|
5659
|
+
path.join(__dirname, "..", "engine", "data"),
|
|
5660
|
+
path.join(__dirname, "..", "src", "engine", "data")
|
|
5661
|
+
];
|
|
5662
|
+
for (const dir of candidates) {
|
|
5663
|
+
try {
|
|
5664
|
+
readFileSync6(path.join(dir, BUNDLE_FILENAME));
|
|
5665
|
+
return dir;
|
|
5666
|
+
} catch {
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
return candidates[0];
|
|
5670
|
+
}
|
|
5671
|
+
function loadBundledSnapshot() {
|
|
5672
|
+
const filePath = path.join(getEngineDataDir(), BUNDLE_FILENAME);
|
|
5673
|
+
let raw;
|
|
5674
|
+
try {
|
|
5675
|
+
raw = readFileSync6(filePath, "utf-8");
|
|
5676
|
+
} catch (err) {
|
|
5677
|
+
throw new Error(
|
|
5678
|
+
`Bundled rulepack snapshot not found at ${filePath}. This indicates a broken install - bundle.appstore.json should always ship with the package. (${err.message})`
|
|
5679
|
+
);
|
|
5680
|
+
}
|
|
5681
|
+
try {
|
|
5682
|
+
return JSON.parse(raw);
|
|
5683
|
+
} catch (err) {
|
|
5684
|
+
throw new Error(`Bundled rulepack snapshot at ${filePath} is not valid JSON: ${err.message}`);
|
|
5685
|
+
}
|
|
5686
|
+
}
|
|
5687
|
+
|
|
5688
|
+
// src/engine/redactor.ts
|
|
5689
|
+
function buildSimpleRules() {
|
|
5690
|
+
return [
|
|
5691
|
+
{
|
|
5692
|
+
kind: "pem",
|
|
5693
|
+
regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
|
|
5694
|
+
},
|
|
5695
|
+
{ kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
5696
|
+
{
|
|
5697
|
+
kind: "openai",
|
|
5698
|
+
regex: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}/g
|
|
5699
|
+
},
|
|
5700
|
+
{ kind: "stripe", regex: /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
|
|
5701
|
+
{ kind: "google", regex: /AIza[0-9A-Za-z_-]{35}/g },
|
|
5702
|
+
{ kind: "aws", regex: /AKIA[0-9A-Z]{16}/g },
|
|
5703
|
+
{ kind: "github", regex: /gh[pousr]_[A-Za-z0-9]{20,}/g },
|
|
5704
|
+
{ kind: "slack", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
|
|
5705
|
+
{
|
|
5706
|
+
kind: "jwt",
|
|
5707
|
+
regex: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
|
|
5708
|
+
}
|
|
5709
|
+
];
|
|
5710
|
+
}
|
|
5711
|
+
function buildGenericRule() {
|
|
5712
|
+
return /(api[_-]?key|apikey|secret|token|passwd|password|client[_-]?secret)(\s*[:=]\s*)(["'])([^"'\n]{12,})(["'])/gi;
|
|
5713
|
+
}
|
|
5714
|
+
var ALREADY_REDACTED = /^\[REDACTED:[A-Za-z0-9_]+\]$/;
|
|
5715
|
+
function redactSecrets(source) {
|
|
5716
|
+
let text = source;
|
|
5717
|
+
let redactedCount = 0;
|
|
5718
|
+
for (const { kind, regex } of buildSimpleRules()) {
|
|
5719
|
+
text = text.replace(regex, () => {
|
|
5720
|
+
redactedCount++;
|
|
5721
|
+
return `[REDACTED:${kind}]`;
|
|
5722
|
+
});
|
|
5723
|
+
}
|
|
5724
|
+
text = text.replace(
|
|
5725
|
+
buildGenericRule(),
|
|
5726
|
+
(full, key, delim, openQuote, value, closeQuote) => {
|
|
5727
|
+
if (ALREADY_REDACTED.test(value)) return full;
|
|
5728
|
+
redactedCount++;
|
|
5729
|
+
return `${key}${delim}${openQuote}[REDACTED:generic]${closeQuote}`;
|
|
5730
|
+
}
|
|
5731
|
+
);
|
|
5732
|
+
return { text, redactedCount };
|
|
5733
|
+
}
|
|
5734
|
+
|
|
5735
|
+
// src/review/feature-detector.ts
|
|
5736
|
+
function getFeatureDiscoveryPrompt() {
|
|
5737
|
+
return `You are an expert app analyst. Your job is to understand WHAT an app does from the USER's perspective by reading its source code. You are NOT reviewing code quality.
|
|
5738
|
+
|
|
5739
|
+
Respond in English.
|
|
5740
|
+
|
|
5741
|
+
Answer this question: "If someone downloads this app and opens it on their iPhone, what will they see and experience?"
|
|
5742
|
+
|
|
5743
|
+
Analyze the source code and identify:
|
|
5744
|
+
|
|
5745
|
+
1. **App Purpose**: What does this app do? (1-2 sentences)
|
|
5746
|
+
2. **User Flows**: What are the main things a user can do? (list each)
|
|
5747
|
+
3. **Onboarding**: What happens when you first open the app? Login required? Permissions asked immediately?
|
|
5748
|
+
4. **Monetization**: How does the app make money? Subscriptions? IAP? Ads? External payments? Free?
|
|
3036
5749
|
5. **Data & Privacy**: What user data does the app collect? What device permissions does it need? Is there a privacy policy link?
|
|
3037
5750
|
6. **Content**: Is there user-generated content (posts, comments, chat)? Third-party content? Web content?
|
|
3038
5751
|
7. **Account Management**: Can users create accounts? Can they delete their accounts?
|
|
@@ -3100,62 +5813,23 @@ function parseFeatureAnalysis(response) {
|
|
|
3100
5813
|
}
|
|
3101
5814
|
|
|
3102
5815
|
// src/review/rag-selector.ts
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
const __dirname = path.dirname(__filename);
|
|
3118
|
-
const candidates = [
|
|
3119
|
-
path.join(__dirname, "rag-data"),
|
|
3120
|
-
path.join(__dirname, "review", "rag-data"),
|
|
3121
|
-
path.join(__dirname, "..", "review", "rag-data"),
|
|
3122
|
-
path.join(__dirname, "..", "src", "review", "rag-data")
|
|
3123
|
-
];
|
|
3124
|
-
for (const dir of candidates) {
|
|
3125
|
-
try {
|
|
3126
|
-
readFileSync6(path.join(dir, "safety.json"));
|
|
3127
|
-
return dir;
|
|
3128
|
-
} catch {
|
|
3129
|
-
}
|
|
3130
|
-
}
|
|
3131
|
-
return candidates[0];
|
|
3132
|
-
}
|
|
3133
|
-
function loadAllStories() {
|
|
3134
|
-
if (cachedStories) return cachedStories;
|
|
3135
|
-
const dataDir = getDataDir();
|
|
3136
|
-
const stories = [];
|
|
3137
|
-
for (const file of DATA_FILES) {
|
|
3138
|
-
try {
|
|
3139
|
-
const content = readFileSync6(path.join(dataDir, file), "utf-8");
|
|
3140
|
-
const parsed = JSON.parse(content);
|
|
3141
|
-
stories.push(...parsed);
|
|
3142
|
-
} catch {
|
|
5816
|
+
function searchStories(stories, signals, maxResults = 20) {
|
|
5817
|
+
if (signals.length === 0) return stories.slice(0, maxResults);
|
|
5818
|
+
const signalKeywords = /* @__PURE__ */ new Set();
|
|
5819
|
+
const signalFeatures = /* @__PURE__ */ new Set();
|
|
5820
|
+
const signalCategories = /* @__PURE__ */ new Set();
|
|
5821
|
+
for (const signal of signals) {
|
|
5822
|
+
if (typeof signal === "string") {
|
|
5823
|
+
const value = signal.toLowerCase();
|
|
5824
|
+
signalFeatures.add(value);
|
|
5825
|
+
signalKeywords.add(value);
|
|
5826
|
+
} else {
|
|
5827
|
+
signalFeatures.add(signal.feature.toLowerCase());
|
|
5828
|
+
for (const keyword of signal.keywords) signalKeywords.add(keyword.toLowerCase());
|
|
5829
|
+
signalCategories.add(signal.category);
|
|
3143
5830
|
}
|
|
3144
5831
|
}
|
|
3145
|
-
|
|
3146
|
-
return stories;
|
|
3147
|
-
}
|
|
3148
|
-
function searchStories(signals, maxResults = 20) {
|
|
3149
|
-
const allStories = loadAllStories();
|
|
3150
|
-
if (signals.length === 0) return allStories.slice(0, maxResults);
|
|
3151
|
-
const signalKeywords = new Set(
|
|
3152
|
-
signals.flatMap((s) => s.keywords.map((k) => k.toLowerCase()))
|
|
3153
|
-
);
|
|
3154
|
-
const signalFeatures = new Set(
|
|
3155
|
-
signals.map((s) => s.feature.toLowerCase())
|
|
3156
|
-
);
|
|
3157
|
-
const signalCategories = new Set(signals.map((s) => s.category));
|
|
3158
|
-
const scored = allStories.map((story) => {
|
|
5832
|
+
const scored = stories.map((story) => {
|
|
3159
5833
|
let score = 0;
|
|
3160
5834
|
for (const signal of story.behavioralSignals) {
|
|
3161
5835
|
const signalLower = signal.toLowerCase();
|
|
@@ -3202,6 +5876,21 @@ You do NOT review code quality. You review the APP \u2014 what a user sees, touc
|
|
|
3202
5876
|
For every issue, think through this exact scenario:
|
|
3203
5877
|
"I downloaded this app on my iPhone. I opened it. I [did something specific]. I saw [specific result]. This violates Guideline [X.X] because [reason]."
|
|
3204
5878
|
|
|
5879
|
+
## STATIC ANALYSIS ALREADY RAN
|
|
5880
|
+
|
|
5881
|
+
A deterministic static rule engine scanned this codebase before you. The user prompt contains a section titled "## Already Detected By Static Analysis (do not repeat)". Those violations are CONFIRMED and already in the final report.
|
|
5882
|
+
|
|
5883
|
+
HARD RULES:
|
|
5884
|
+
1. You MUST NOT re-report anything listed in that section. A finding that duplicates a listed guideline violation will be discarded.
|
|
5885
|
+
2. Focus on what static analysis CANNOT see:
|
|
5886
|
+
- Minimum functionality and app completeness (Guideline 4.2): is this a real, useful app or a thin shell?
|
|
5887
|
+
- Spam, template, and repetitive app patterns (Guideline 4.3)
|
|
5888
|
+
- User-generated content without moderation: report, block, and filter mechanisms (Guideline 1.2)
|
|
5889
|
+
- Misleading claims, undelivered features, fake functionality (Guideline 2.3.1)
|
|
5890
|
+
- Purchase flows that bypass or confuse Apple's in-app purchase system (Guidelines 3.1.x)
|
|
5891
|
+
- Objectionable or age-inappropriate content (Guideline 1.1)
|
|
5892
|
+
3. Every finding MUST cite a specific guideline number AND describe the user-visible impact.
|
|
5893
|
+
|
|
3205
5894
|
## WHAT YOU CHECK (as if using the app)
|
|
3206
5895
|
|
|
3207
5896
|
1. **First Launch**: What happens when I open the app? Does it crash? Show a blank screen? Force me to log in? Ask for too many permissions at once?
|
|
@@ -3252,11 +5941,11 @@ You MUST respond with valid JSON:
|
|
|
3252
5941
|
"relatedRejection": "string | null (reference to a similar rejection case if provided)"
|
|
3253
5942
|
}
|
|
3254
5943
|
],
|
|
3255
|
-
"overallAssessment": "string (2-3 sentence assessment of the app's review readiness)"
|
|
3256
|
-
"estimatedOutcome": "likely-approved | likely-rejected | needs-attention",
|
|
3257
|
-
"estimatedApprovalChance": 0-100
|
|
5944
|
+
"overallAssessment": "string (2-3 sentence assessment of the app's review readiness)"
|
|
3258
5945
|
}
|
|
3259
5946
|
|
|
5947
|
+
Do not include an approval estimate; the final outcome is computed deterministically from the merged findings.
|
|
5948
|
+
|
|
3260
5949
|
## IMPORTANT RULES
|
|
3261
5950
|
|
|
3262
5951
|
1. Every finding MUST describe user-visible behavior, not code patterns
|
|
@@ -3306,14 +5995,8 @@ function validateCategory(c) {
|
|
|
3306
5995
|
}
|
|
3307
5996
|
return "performance";
|
|
3308
5997
|
}
|
|
3309
|
-
function validateOutcome(o) {
|
|
3310
|
-
if (["likely-approved", "likely-rejected", "needs-attention"].includes(o)) {
|
|
3311
|
-
return o;
|
|
3312
|
-
}
|
|
3313
|
-
return "needs-attention";
|
|
3314
|
-
}
|
|
3315
5998
|
function convertFindings(raw) {
|
|
3316
|
-
return raw.findings.map((f, i) => ({
|
|
5999
|
+
return (raw.findings ?? []).map((f, i) => ({
|
|
3317
6000
|
id: f.ruleId || `finding-${i + 1}`,
|
|
3318
6001
|
title: f.title,
|
|
3319
6002
|
risk_level: validateRiskLevel(f.severity),
|
|
@@ -3326,51 +6009,164 @@ function convertFindings(raw) {
|
|
|
3326
6009
|
evidence: f.evidence ?? "",
|
|
3327
6010
|
recommendation: f.fixSuggestion,
|
|
3328
6011
|
file: f.file ?? void 0,
|
|
3329
|
-
related_story_id: f.relatedRejection ?? void 0
|
|
6012
|
+
related_story_id: f.relatedRejection ?? void 0,
|
|
6013
|
+
// No rule_id: AI findings have no backing rulepack rule.
|
|
6014
|
+
detection: "ai"
|
|
3330
6015
|
}));
|
|
3331
6016
|
}
|
|
3332
|
-
function
|
|
3333
|
-
|
|
3334
|
-
const medium = findings.filter((f) => f.risk_level === "medium").length;
|
|
3335
|
-
const low = findings.filter((f) => f.risk_level === "low").length;
|
|
3336
|
-
const info = findings.filter((f) => f.risk_level === "info").length;
|
|
3337
|
-
const outcome = validateOutcome(aiResponse.estimatedOutcome);
|
|
3338
|
-
let approvalChance = aiResponse.estimatedApprovalChance ?? 75;
|
|
3339
|
-
if (outcome === "likely-rejected") approvalChance = Math.min(approvalChance, 30);
|
|
3340
|
-
if (outcome === "likely-approved") approvalChance = Math.max(approvalChance, 70);
|
|
3341
|
-
const topRisks = findings.filter((f) => f.risk_level === "high" || f.risk_level === "medium").slice(0, 3).map((f) => f.title);
|
|
3342
|
-
return {
|
|
3343
|
-
total_findings: findings.length,
|
|
3344
|
-
high_risk: high,
|
|
3345
|
-
medium_risk: medium,
|
|
3346
|
-
low_risk: low,
|
|
3347
|
-
info,
|
|
3348
|
-
outcome,
|
|
3349
|
-
estimated_approval_chance: approvalChance,
|
|
3350
|
-
top_risks: topRisks,
|
|
3351
|
-
ai_assessment: aiResponse.overallAssessment
|
|
3352
|
-
};
|
|
6017
|
+
function normalizeTitle(title) {
|
|
6018
|
+
return title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
3353
6019
|
}
|
|
3354
|
-
|
|
3355
|
-
const
|
|
6020
|
+
function dedupeAIFindings(aiFindings, staticFindings) {
|
|
6021
|
+
const kept = aiFindings.filter((ai) => {
|
|
6022
|
+
const aiTitle = normalizeTitle(ai.title ?? "");
|
|
6023
|
+
return !staticFindings.some((st) => {
|
|
6024
|
+
if (st.guideline_number !== ai.guideline_number) return false;
|
|
6025
|
+
const stTitle = normalizeTitle(st.title ?? "");
|
|
6026
|
+
if (!aiTitle || !stTitle) return false;
|
|
6027
|
+
return stTitle.includes(aiTitle) || aiTitle.includes(stTitle);
|
|
6028
|
+
});
|
|
6029
|
+
});
|
|
6030
|
+
return { kept, droppedCount: aiFindings.length - kept.length };
|
|
6031
|
+
}
|
|
6032
|
+
var FEATURE_CAPABILITY_MAP = {
|
|
6033
|
+
push_notifications: "push_notifications",
|
|
6034
|
+
push_notification: "push_notifications",
|
|
6035
|
+
iap: "iap",
|
|
6036
|
+
in_app_purchase: "iap",
|
|
6037
|
+
in_app_purchases: "iap",
|
|
6038
|
+
subscription: "subscriptions",
|
|
6039
|
+
subscriptions: "subscriptions",
|
|
6040
|
+
ads: "ads",
|
|
6041
|
+
advertising: "ads",
|
|
6042
|
+
advertisements: "ads",
|
|
6043
|
+
tracking: "tracking",
|
|
6044
|
+
health: "health",
|
|
6045
|
+
healthkit: "health",
|
|
6046
|
+
location: "location",
|
|
6047
|
+
location_tracking: "location",
|
|
6048
|
+
camera: "camera",
|
|
6049
|
+
camera_usage: "camera",
|
|
6050
|
+
microphone: "microphone",
|
|
6051
|
+
contacts: "contacts",
|
|
6052
|
+
photos: "photos",
|
|
6053
|
+
photo_library: "photos",
|
|
6054
|
+
bluetooth: "bluetooth",
|
|
6055
|
+
background_audio: "background_audio",
|
|
6056
|
+
background_location: "background_location",
|
|
6057
|
+
account_creation: "account_creation",
|
|
6058
|
+
account_deletion: "account_deletion",
|
|
6059
|
+
sign_in_with_apple: "sign_in_with_apple",
|
|
6060
|
+
third_party_login: "third_party_login",
|
|
6061
|
+
social_login: "third_party_login",
|
|
6062
|
+
webview: "webview_heavy",
|
|
6063
|
+
webview_heavy: "webview_heavy",
|
|
6064
|
+
ugc: "ugc",
|
|
6065
|
+
user_generated_content: "ugc",
|
|
6066
|
+
external_payment: "external_payment",
|
|
6067
|
+
external_payments: "external_payment",
|
|
6068
|
+
kids_category: "kids_category",
|
|
6069
|
+
ai_generated_content: "ai_generated_content",
|
|
6070
|
+
gambling: "gambling",
|
|
6071
|
+
crypto: "crypto",
|
|
6072
|
+
cryptocurrency: "crypto"
|
|
6073
|
+
};
|
|
6074
|
+
function mapFeaturesToCapabilities(analysis) {
|
|
6075
|
+
const caps = /* @__PURE__ */ new Set();
|
|
6076
|
+
for (const feature of analysis.features ?? []) {
|
|
6077
|
+
const key = feature.feature.toLowerCase().trim().replace(/[\s-]+/g, "_");
|
|
6078
|
+
const cap = FEATURE_CAPABILITY_MAP[key];
|
|
6079
|
+
if (cap) caps.add(cap);
|
|
6080
|
+
}
|
|
6081
|
+
if (analysis.appType === "webview-heavy" || analysis.appType === "web-wrapper") {
|
|
6082
|
+
caps.add("webview_heavy");
|
|
6083
|
+
}
|
|
6084
|
+
return [...caps].sort();
|
|
6085
|
+
}
|
|
6086
|
+
function formatValueOrUnknown(value) {
|
|
6087
|
+
if (value === null) return "unknown";
|
|
6088
|
+
return value ? "yes" : "no";
|
|
6089
|
+
}
|
|
6090
|
+
function formatProfileDigest(profile) {
|
|
6091
|
+
const lines = [];
|
|
6092
|
+
lines.push(`- Stack: ${profile.stack_label} (platforms: ${profile.platforms.join(", ") || "unknown"})`);
|
|
6093
|
+
if (profile.app.bundle_id) lines.push(`- Bundle ID: ${profile.app.bundle_id}`);
|
|
6094
|
+
if (profile.sdks.length > 0) {
|
|
6095
|
+
const byCategory = /* @__PURE__ */ new Map();
|
|
6096
|
+
for (const sdk of profile.sdks) {
|
|
6097
|
+
const names = byCategory.get(sdk.category) ?? [];
|
|
6098
|
+
names.push(sdk.name);
|
|
6099
|
+
byCategory.set(sdk.category, names);
|
|
6100
|
+
}
|
|
6101
|
+
const parts = [...byCategory.entries()].map(([category, names]) => `${category}: ${names.join(", ")}`);
|
|
6102
|
+
lines.push(`- Detected SDKs: ${parts.join("; ")}`);
|
|
6103
|
+
} else {
|
|
6104
|
+
lines.push("- Detected SDKs: none");
|
|
6105
|
+
}
|
|
6106
|
+
lines.push(`- Capabilities: ${profile.capabilities.join(", ") || "none detected"}`);
|
|
6107
|
+
const plist3 = profile.ios?.plist;
|
|
6108
|
+
if (plist3) {
|
|
6109
|
+
const usageKeys = Object.keys(plist3.usage_descriptions);
|
|
6110
|
+
lines.push(`- iOS usage descriptions declared: ${usageKeys.length > 0 ? usageKeys.join(", ") : "none"}`);
|
|
6111
|
+
if (plist3.background_modes.length > 0) {
|
|
6112
|
+
lines.push(`- iOS background modes: ${plist3.background_modes.join(", ")}`);
|
|
6113
|
+
}
|
|
6114
|
+
if (plist3.ats_allows_arbitrary_loads === true) {
|
|
6115
|
+
lines.push("- ATS: NSAllowsArbitraryLoads is enabled");
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
6118
|
+
if (profile.platforms.includes("ios")) {
|
|
6119
|
+
const manifest = profile.ios?.privacy_manifest;
|
|
6120
|
+
lines.push(`- Privacy manifest: ${manifest?.app_manifest_present ? "present" : "not found"}`);
|
|
6121
|
+
}
|
|
6122
|
+
if (profile.android?.manifest_found) {
|
|
6123
|
+
lines.push(`- Android permissions: ${profile.android.permissions.join(", ") || "none"}`);
|
|
6124
|
+
}
|
|
6125
|
+
const signals = profile.signals;
|
|
6126
|
+
lines.push(
|
|
6127
|
+
`- Source signals: restore purchases ${formatValueOrUnknown(signals.restore_purchases_found)}, account deletion ${formatValueOrUnknown(signals.account_deletion_found)}, privacy policy link ${formatValueOrUnknown(signals.privacy_policy_link_found)}, external checkout URL ${formatValueOrUnknown(signals.external_checkout_url_found)}`
|
|
6128
|
+
);
|
|
6129
|
+
return lines.join("\n");
|
|
6130
|
+
}
|
|
6131
|
+
function formatAlreadyDetected(staticFindings) {
|
|
6132
|
+
if (staticFindings.length === 0) {
|
|
6133
|
+
return "(none - the static rule engine found no definitive violations)";
|
|
6134
|
+
}
|
|
6135
|
+
return staticFindings.map((f) => `- [${f.rule_id ?? f.id}] ${f.title} (Guideline ${f.guideline_number})`).join("\n");
|
|
6136
|
+
}
|
|
6137
|
+
async function runCodeReview(input) {
|
|
6138
|
+
let profile = input.profile;
|
|
6139
|
+
let checks = input.staticChecks;
|
|
6140
|
+
let staticFindings = input.staticFindings;
|
|
3356
6141
|
let featureAnalysis = null;
|
|
3357
|
-
|
|
3358
|
-
if (deep) {
|
|
6142
|
+
if (input.deep) {
|
|
3359
6143
|
featureAnalysis = await detectFeatures(
|
|
3360
|
-
provider,
|
|
3361
|
-
platform,
|
|
3362
|
-
appName,
|
|
3363
|
-
bundleId,
|
|
3364
|
-
|
|
6144
|
+
input.provider,
|
|
6145
|
+
input.platform,
|
|
6146
|
+
input.appName,
|
|
6147
|
+
input.bundleId,
|
|
6148
|
+
input.sourceContext
|
|
3365
6149
|
);
|
|
3366
6150
|
if (featureAnalysis) {
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
6151
|
+
const discovered = mapFeaturesToCapabilities(featureAnalysis);
|
|
6152
|
+
const added = discovered.filter((cap) => !profile.capabilities.includes(cap));
|
|
6153
|
+
if (added.length > 0) {
|
|
6154
|
+
profile = {
|
|
6155
|
+
...profile,
|
|
6156
|
+
capabilities: [...profile.capabilities, ...added].sort()
|
|
6157
|
+
};
|
|
6158
|
+
const rerun = evaluateRules(profile, input.bundle);
|
|
6159
|
+
checks = rerun.checks;
|
|
6160
|
+
staticFindings = rerun.findings;
|
|
6161
|
+
}
|
|
3370
6162
|
}
|
|
3371
|
-
} else {
|
|
3372
|
-
relevantStories = loadAllStories().slice(0, 15);
|
|
3373
6163
|
}
|
|
6164
|
+
const profileSignals = [
|
|
6165
|
+
...profile.sdks.map((sdk) => sdk.id),
|
|
6166
|
+
...profile.capabilities
|
|
6167
|
+
];
|
|
6168
|
+
const signals = featureAnalysis ? [...featureAnalysis.features, ...profileSignals] : profileSignals;
|
|
6169
|
+
const relevantStories = searchStories(input.bundle.stories, signals, input.deep ? 20 : 15);
|
|
3374
6170
|
const storiesText = formatStoriesForPrompt(relevantStories);
|
|
3375
6171
|
const featureSection = featureAnalysis ? `## App Analysis (from feature discovery)
|
|
3376
6172
|
|
|
@@ -3381,60 +6177,136 @@ async function runCodeReview(provider, platform, appName, bundleId, sourceCode,
|
|
|
3381
6177
|
${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confidence: ${f.confidence})`).join("\n")}
|
|
3382
6178
|
|
|
3383
6179
|
**Risk Areas:**
|
|
3384
|
-
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
|
|
6180
|
+
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
|
|
6181
|
+
|
|
6182
|
+
` : "";
|
|
3385
6183
|
const userPrompt = `## App Information
|
|
3386
|
-
- Platform: ${platform}
|
|
3387
|
-
- App Name: ${appName}
|
|
3388
|
-
- Bundle ID: ${bundleId}
|
|
6184
|
+
- Platform: ${input.platform}
|
|
6185
|
+
- App Name: ${input.appName}
|
|
6186
|
+
- Bundle ID: ${input.bundleId}
|
|
3389
6187
|
|
|
3390
|
-
|
|
6188
|
+
## App Profile (from static analysis)
|
|
6189
|
+
${formatProfileDigest(profile)}
|
|
3391
6190
|
|
|
3392
|
-
##
|
|
6191
|
+
## Already Detected By Static Analysis (do not repeat)
|
|
6192
|
+
These violations are already confirmed and included in the report. Do NOT re-report them:
|
|
6193
|
+
|
|
6194
|
+
${formatAlreadyDetected(staticFindings)}
|
|
6195
|
+
|
|
6196
|
+
${featureSection}## Real Rejection Cases (from other developers)
|
|
3393
6197
|
These are REAL cases where Apple rejected similar apps. Use them to calibrate:
|
|
3394
6198
|
|
|
3395
6199
|
${storiesText}
|
|
3396
6200
|
|
|
3397
|
-
## Source Code
|
|
6201
|
+
## Source Code Context
|
|
3398
6202
|
|
|
3399
|
-
${
|
|
6203
|
+
${input.sourceContext}
|
|
3400
6204
|
|
|
3401
|
-
Now review this app as an Apple reviewer testing it on a device. What would you flag?`;
|
|
6205
|
+
Now review this app as an Apple reviewer testing it on a device. Focus on behavioral issues that static analysis cannot detect. What would you flag?`;
|
|
3402
6206
|
const systemPrompt = getReviewerSimulationPrompt();
|
|
3403
|
-
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
6207
|
+
const response = await input.provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
3404
6208
|
const parsed = parseAIResponse(response);
|
|
6209
|
+
const featuresDetected = featureAnalysis?.features.map((f) => f.feature) ?? [];
|
|
3405
6210
|
if (!parsed) {
|
|
3406
6211
|
return {
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
medium_risk: 0,
|
|
3416
|
-
low_risk: 0,
|
|
3417
|
-
info: 0,
|
|
3418
|
-
outcome: "needs-attention",
|
|
3419
|
-
estimated_approval_chance: 50,
|
|
3420
|
-
top_risks: [],
|
|
3421
|
-
ai_assessment: "AI analysis could not produce structured results."
|
|
3422
|
-
},
|
|
3423
|
-
rag_stories_used: relevantStories.length,
|
|
3424
|
-
scan_duration_ms: Date.now() - startTime
|
|
6212
|
+
aiFindings: [],
|
|
6213
|
+
aiAssessment: "AI analysis could not produce structured results.",
|
|
6214
|
+
featuresDetected,
|
|
6215
|
+
ragStoriesUsed: relevantStories.length,
|
|
6216
|
+
dedupedCount: 0,
|
|
6217
|
+
profile,
|
|
6218
|
+
checks,
|
|
6219
|
+
staticFindings
|
|
3425
6220
|
};
|
|
3426
6221
|
}
|
|
3427
|
-
const
|
|
3428
|
-
const
|
|
6222
|
+
const converted = convertFindings(parsed);
|
|
6223
|
+
const { kept, droppedCount } = dedupeAIFindings(converted, staticFindings);
|
|
6224
|
+
return {
|
|
6225
|
+
aiFindings: kept,
|
|
6226
|
+
aiAssessment: parsed.overallAssessment || "AI review completed.",
|
|
6227
|
+
featuresDetected,
|
|
6228
|
+
ragStoriesUsed: relevantStories.length,
|
|
6229
|
+
dedupedCount: droppedCount,
|
|
6230
|
+
profile,
|
|
6231
|
+
checks,
|
|
6232
|
+
staticFindings
|
|
6233
|
+
};
|
|
6234
|
+
}
|
|
6235
|
+
|
|
6236
|
+
// src/review/report-builder.ts
|
|
6237
|
+
var SEVERITY_ORDER2 = {
|
|
6238
|
+
high: 0,
|
|
6239
|
+
medium: 1,
|
|
6240
|
+
low: 2,
|
|
6241
|
+
info: 3
|
|
6242
|
+
};
|
|
6243
|
+
function buildDeterministicSummary(findings, opts) {
|
|
6244
|
+
const high = findings.filter((f) => f.risk_level === "high").length;
|
|
6245
|
+
const medium = findings.filter((f) => f.risk_level === "medium").length;
|
|
6246
|
+
const low = findings.filter((f) => f.risk_level === "low").length;
|
|
6247
|
+
const info = findings.filter((f) => f.risk_level === "info").length;
|
|
6248
|
+
const rawChance = 85 - 20 * high - 5 * medium - 1 * low;
|
|
6249
|
+
const estimatedApprovalChance = Math.min(95, Math.max(5, rawChance));
|
|
6250
|
+
let outcome;
|
|
6251
|
+
if (high >= 2) {
|
|
6252
|
+
outcome = "likely-rejected";
|
|
6253
|
+
} else if (high >= 1 || medium >= 3) {
|
|
6254
|
+
outcome = "needs-attention";
|
|
6255
|
+
} else {
|
|
6256
|
+
outcome = "likely-approved";
|
|
6257
|
+
}
|
|
6258
|
+
const topRisks = [...findings].sort((a, b) => SEVERITY_ORDER2[a.risk_level] - SEVERITY_ORDER2[b.risk_level]).slice(0, 3).map((f) => f.title);
|
|
6259
|
+
let aiAssessment;
|
|
6260
|
+
if (opts.aiReviewRan && opts.aiAssessment) {
|
|
6261
|
+
aiAssessment = opts.aiAssessment;
|
|
6262
|
+
} else {
|
|
6263
|
+
const violations = findings.length === 1 ? "violation" : "violations";
|
|
6264
|
+
aiAssessment = `Static rule scan found ${findings.length} guideline ${violations} across ${opts.checksCount ?? 0} checks. AI behavioral review was not run.`;
|
|
6265
|
+
}
|
|
6266
|
+
return {
|
|
6267
|
+
total_findings: findings.length,
|
|
6268
|
+
high_risk: high,
|
|
6269
|
+
medium_risk: medium,
|
|
6270
|
+
low_risk: low,
|
|
6271
|
+
info,
|
|
6272
|
+
outcome,
|
|
6273
|
+
estimated_approval_chance: estimatedApprovalChance,
|
|
6274
|
+
top_risks: topRisks,
|
|
6275
|
+
ai_assessment: aiAssessment
|
|
6276
|
+
};
|
|
6277
|
+
}
|
|
6278
|
+
var RAW_DEPS_CAP = 50;
|
|
6279
|
+
function trimProfileForReport(profile) {
|
|
6280
|
+
const raw = profile.raw_dependencies;
|
|
6281
|
+
const trimmed = Object.fromEntries(
|
|
6282
|
+
Object.entries(raw).map(([bucket, entries]) => [bucket, entries.slice(0, RAW_DEPS_CAP)])
|
|
6283
|
+
);
|
|
6284
|
+
return { ...profile, raw_dependencies: trimmed };
|
|
6285
|
+
}
|
|
6286
|
+
function assembleReportV2(input) {
|
|
6287
|
+
const findings = [...input.staticFindings, ...input.aiFindings];
|
|
6288
|
+
const summary = buildDeterministicSummary(findings, {
|
|
6289
|
+
aiReviewRan: input.aiReviewRan,
|
|
6290
|
+
aiAssessment: input.aiAssessment,
|
|
6291
|
+
checksCount: input.checks.length
|
|
6292
|
+
});
|
|
3429
6293
|
return {
|
|
3430
|
-
features_detected:
|
|
3431
|
-
source_files_analyzed:
|
|
3432
|
-
platform,
|
|
3433
|
-
bundle_id: bundleId
|
|
6294
|
+
features_detected: input.featuresDetected,
|
|
6295
|
+
source_files_analyzed: input.sourceFilesAnalyzed,
|
|
6296
|
+
platform: input.platform,
|
|
6297
|
+
bundle_id: input.bundleId,
|
|
3434
6298
|
findings,
|
|
3435
6299
|
summary,
|
|
3436
|
-
rag_stories_used:
|
|
3437
|
-
scan_duration_ms:
|
|
6300
|
+
rag_stories_used: input.ragStoriesUsed,
|
|
6301
|
+
scan_duration_ms: input.scanDurationMs,
|
|
6302
|
+
report_schema_version: 2,
|
|
6303
|
+
engine_version: input.engineVersion,
|
|
6304
|
+
rulepack_version: input.rulepack.version,
|
|
6305
|
+
rulepack_store: input.rulepack.store,
|
|
6306
|
+
rulepack_source: input.rulepack.source,
|
|
6307
|
+
app_profile: trimProfileForReport(input.profile),
|
|
6308
|
+
checks: input.checks,
|
|
6309
|
+
ai_review_ran: input.aiReviewRan
|
|
3438
6310
|
};
|
|
3439
6311
|
}
|
|
3440
6312
|
|
|
@@ -3458,6 +6330,7 @@ var RISK_COLORS = {
|
|
|
3458
6330
|
low: chalk2.blue,
|
|
3459
6331
|
info: chalk2.gray
|
|
3460
6332
|
};
|
|
6333
|
+
var STATIC_ONLY_BANNER = "AI behavioral review skipped. Static rule results only.";
|
|
3461
6334
|
function outcomeDisplay(outcome) {
|
|
3462
6335
|
switch (outcome) {
|
|
3463
6336
|
case "likely-approved":
|
|
@@ -3480,6 +6353,30 @@ function approvalBar(chance) {
|
|
|
3480
6353
|
else color = chalk2.red;
|
|
3481
6354
|
return color("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty)) + ` ${chance}%`;
|
|
3482
6355
|
}
|
|
6356
|
+
function groupChecks(checks) {
|
|
6357
|
+
return {
|
|
6358
|
+
failed: checks.filter((c) => c.status === "fail"),
|
|
6359
|
+
unverified: checks.filter((c) => c.status === "unverified"),
|
|
6360
|
+
passed: checks.filter((c) => c.status === "pass"),
|
|
6361
|
+
na: checks.filter((c) => c.status === "na")
|
|
6362
|
+
};
|
|
6363
|
+
}
|
|
6364
|
+
function detectionBadge(finding) {
|
|
6365
|
+
if (finding.detection === "static") return chalk2.dim("[static]") + " ";
|
|
6366
|
+
if (finding.detection === "ai") return chalk2.cyan("[AI]") + " ";
|
|
6367
|
+
return "";
|
|
6368
|
+
}
|
|
6369
|
+
function versionFooter(report) {
|
|
6370
|
+
const parts = [];
|
|
6371
|
+
if (report.rulepack_version) {
|
|
6372
|
+
const source = report.rulepack_source ? ` (${report.rulepack_source})` : "";
|
|
6373
|
+
parts.push(`Rulepack v${report.rulepack_version}${source}`);
|
|
6374
|
+
}
|
|
6375
|
+
if (report.engine_version) {
|
|
6376
|
+
parts.push(`Engine v${report.engine_version}`);
|
|
6377
|
+
}
|
|
6378
|
+
return parts.length > 0 ? parts.join(" \xB7 ") : null;
|
|
6379
|
+
}
|
|
3483
6380
|
function formatTerminal(report) {
|
|
3484
6381
|
const lines = [];
|
|
3485
6382
|
lines.push("");
|
|
@@ -3503,7 +6400,9 @@ function formatTerminal(report) {
|
|
|
3503
6400
|
report.summary.low_risk > 0 && chalk2.blue(`${report.summary.low_risk} low`),
|
|
3504
6401
|
report.summary.info > 0 && chalk2.gray(`${report.summary.info} info`)
|
|
3505
6402
|
].filter(Boolean);
|
|
3506
|
-
lines.push(
|
|
6403
|
+
lines.push(
|
|
6404
|
+
` Findings: ${chalk2.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
|
|
6405
|
+
);
|
|
3507
6406
|
lines.push("");
|
|
3508
6407
|
if (report.features_detected.length > 0) {
|
|
3509
6408
|
lines.push(` Features: ${chalk2.gray(report.features_detected.join(", "))}`);
|
|
@@ -3515,6 +6414,29 @@ function formatTerminal(report) {
|
|
|
3515
6414
|
lines.push(chalk2.gray(" \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3516
6415
|
lines.push("");
|
|
3517
6416
|
}
|
|
6417
|
+
if (report.checks && report.checks.length > 0) {
|
|
6418
|
+
const groups = groupChecks(report.checks);
|
|
6419
|
+
lines.push(chalk2.bold(" COMPLIANCE CHECKS"));
|
|
6420
|
+
lines.push(
|
|
6421
|
+
` ${chalk2.red(`${groups.failed.length} failed`)}, ${chalk2.yellow(`${groups.unverified.length} verify manually`)}, ${chalk2.green(`${groups.passed.length} passed`)}, ${chalk2.gray(`${groups.na.length} not applicable`)}`
|
|
6422
|
+
);
|
|
6423
|
+
lines.push("");
|
|
6424
|
+
for (const check of groups.failed) {
|
|
6425
|
+
lines.push(` ${chalk2.red(`\u2717 [${check.guideline_number}] ${check.title}`)}`);
|
|
6426
|
+
}
|
|
6427
|
+
for (const check of groups.unverified) {
|
|
6428
|
+
lines.push(` ${chalk2.yellow(`VERIFY: [${check.guideline_number}] ${check.title}`)}`);
|
|
6429
|
+
}
|
|
6430
|
+
if (groups.passed.length > 0) {
|
|
6431
|
+
const noun = groups.passed.length === 1 ? "check" : "checks";
|
|
6432
|
+
lines.push(
|
|
6433
|
+
chalk2.gray(
|
|
6434
|
+
` ${groups.passed.length} ${noun} passed (run with --format json for the full list)`
|
|
6435
|
+
)
|
|
6436
|
+
);
|
|
6437
|
+
}
|
|
6438
|
+
lines.push("");
|
|
6439
|
+
}
|
|
3518
6440
|
const findingsByRisk = {
|
|
3519
6441
|
high: [],
|
|
3520
6442
|
medium: [],
|
|
@@ -3532,10 +6454,10 @@ function formatTerminal(report) {
|
|
|
3532
6454
|
const label = RISK_LABELS[level];
|
|
3533
6455
|
const color = RISK_COLORS[level];
|
|
3534
6456
|
lines.push(chalk2.gray(" \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3535
|
-
lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number}
|
|
6457
|
+
lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} - ${finding.guideline_name}`);
|
|
3536
6458
|
lines.push(chalk2.gray(" \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
3537
6459
|
lines.push("");
|
|
3538
|
-
lines.push(` ${chalk2.bold(finding.title)}`);
|
|
6460
|
+
lines.push(` ${detectionBadge(finding)}${chalk2.bold(finding.title)}`);
|
|
3539
6461
|
lines.push(` ${finding.description}`);
|
|
3540
6462
|
lines.push("");
|
|
3541
6463
|
lines.push(` ${chalk2.cyan("User Impact:")} ${finding.user_impact}`);
|
|
@@ -3554,7 +6476,14 @@ function formatTerminal(report) {
|
|
|
3554
6476
|
lines.push("");
|
|
3555
6477
|
}
|
|
3556
6478
|
lines.push(chalk2.gray(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
|
|
3557
|
-
|
|
6479
|
+
if (report.ai_review_ran === false) {
|
|
6480
|
+
lines.push(chalk2.yellow(` ${STATIC_ONLY_BANNER}`));
|
|
6481
|
+
}
|
|
6482
|
+
const footer = versionFooter(report);
|
|
6483
|
+
if (footer) {
|
|
6484
|
+
lines.push(chalk2.gray(` ${footer}`));
|
|
6485
|
+
}
|
|
6486
|
+
lines.push(chalk2.gray(" Powered by Forvibe CLI (forvibe.app)"));
|
|
3558
6487
|
lines.push("");
|
|
3559
6488
|
return lines.join("\n");
|
|
3560
6489
|
}
|
|
@@ -3573,6 +6502,10 @@ function formatMarkdown(report) {
|
|
|
3573
6502
|
lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
|
|
3574
6503
|
lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
|
|
3575
6504
|
lines.push("");
|
|
6505
|
+
if (report.ai_review_ran === false) {
|
|
6506
|
+
lines.push(`> ${STATIC_ONLY_BANNER}`);
|
|
6507
|
+
lines.push("");
|
|
6508
|
+
}
|
|
3576
6509
|
lines.push("## Result");
|
|
3577
6510
|
lines.push("");
|
|
3578
6511
|
lines.push(`**Outcome:** ${report.summary.outcome.replace(/-/g, " ").toUpperCase()}`);
|
|
@@ -3590,6 +6523,26 @@ function formatMarkdown(report) {
|
|
|
3590
6523
|
lines.push(`> ${report.summary.ai_assessment}`);
|
|
3591
6524
|
lines.push("");
|
|
3592
6525
|
}
|
|
6526
|
+
if (report.checks && report.checks.length > 0) {
|
|
6527
|
+
const groups = groupChecks(report.checks);
|
|
6528
|
+
lines.push("## Compliance Checks");
|
|
6529
|
+
lines.push("");
|
|
6530
|
+
lines.push(
|
|
6531
|
+
`${groups.failed.length} failed, ${groups.unverified.length} verify manually, ${groups.passed.length} passed, ${groups.na.length} not applicable`
|
|
6532
|
+
);
|
|
6533
|
+
lines.push("");
|
|
6534
|
+
for (const check of groups.failed) {
|
|
6535
|
+
lines.push(`- FAILED: [${check.guideline_number}] ${check.title}`);
|
|
6536
|
+
}
|
|
6537
|
+
for (const check of groups.unverified) {
|
|
6538
|
+
lines.push(`- VERIFY: [${check.guideline_number}] ${check.title}`);
|
|
6539
|
+
}
|
|
6540
|
+
if (groups.passed.length > 0) {
|
|
6541
|
+
const noun = groups.passed.length === 1 ? "check" : "checks";
|
|
6542
|
+
lines.push(`- ${groups.passed.length} ${noun} passed (run with \`--format json\` for the full list)`);
|
|
6543
|
+
}
|
|
6544
|
+
lines.push("");
|
|
6545
|
+
}
|
|
3593
6546
|
if (report.features_detected.length > 0) {
|
|
3594
6547
|
lines.push("## Detected Features");
|
|
3595
6548
|
lines.push("");
|
|
@@ -3603,13 +6556,15 @@ function formatMarkdown(report) {
|
|
|
3603
6556
|
if (report.findings.length === 0) {
|
|
3604
6557
|
lines.push("No significant issues found. Your app looks ready for review!");
|
|
3605
6558
|
lines.push("");
|
|
6559
|
+
appendMarkdownFooter(lines, report);
|
|
3606
6560
|
return lines.join("\n");
|
|
3607
6561
|
}
|
|
3608
6562
|
for (const finding of report.findings) {
|
|
3609
6563
|
const riskEmoji = RISK_ICONS[finding.risk_level];
|
|
3610
|
-
|
|
6564
|
+
const badge = finding.detection === "static" ? "[static] " : finding.detection === "ai" ? "[AI] " : "";
|
|
6565
|
+
lines.push(`### ${riskEmoji} ${badge}${finding.title}`);
|
|
3611
6566
|
lines.push("");
|
|
3612
|
-
lines.push(`**Guideline:** ${finding.guideline_number}
|
|
6567
|
+
lines.push(`**Guideline:** ${finding.guideline_number} - ${finding.guideline_name}`);
|
|
3613
6568
|
lines.push(`**Risk Level:** ${finding.risk_level}`);
|
|
3614
6569
|
lines.push(`**Category:** ${finding.category}`);
|
|
3615
6570
|
lines.push("");
|
|
@@ -3626,12 +6581,36 @@ function formatMarkdown(report) {
|
|
|
3626
6581
|
lines.push("---");
|
|
3627
6582
|
lines.push("");
|
|
3628
6583
|
}
|
|
3629
|
-
lines
|
|
3630
|
-
lines.push("");
|
|
6584
|
+
appendMarkdownFooter(lines, report);
|
|
3631
6585
|
return lines.join("\n");
|
|
3632
6586
|
}
|
|
6587
|
+
function appendMarkdownFooter(lines, report) {
|
|
6588
|
+
const footer = versionFooter(report);
|
|
6589
|
+
if (footer) {
|
|
6590
|
+
lines.push(`*${footer}*`);
|
|
6591
|
+
lines.push("");
|
|
6592
|
+
}
|
|
6593
|
+
lines.push("*Powered by Forvibe CLI (forvibe.app)*");
|
|
6594
|
+
lines.push("");
|
|
6595
|
+
}
|
|
6596
|
+
|
|
6597
|
+
// src/utils/version.ts
|
|
6598
|
+
import { createRequire } from "module";
|
|
6599
|
+
var FALLBACK_VERSION = "0.0.0";
|
|
6600
|
+
function getCliVersion() {
|
|
6601
|
+
const require2 = createRequire(import.meta.url);
|
|
6602
|
+
for (const candidate of ["../package.json", "../../package.json"]) {
|
|
6603
|
+
try {
|
|
6604
|
+
const pkg = require2(candidate);
|
|
6605
|
+
if (pkg?.version && pkg.name === "@forvibe/cli") return pkg.version;
|
|
6606
|
+
} catch {
|
|
6607
|
+
}
|
|
6608
|
+
}
|
|
6609
|
+
return FALLBACK_VERSION;
|
|
6610
|
+
}
|
|
3633
6611
|
|
|
3634
6612
|
// src/commands/review.ts
|
|
6613
|
+
var DEFAULT_RULEPACK_BASE_URL = "https://forvibe.app";
|
|
3635
6614
|
function askQuestion2(question) {
|
|
3636
6615
|
const rl = createInterface2({
|
|
3637
6616
|
input: process.stdin,
|
|
@@ -3647,55 +6626,14 @@ function askQuestion2(question) {
|
|
|
3647
6626
|
async function reviewCommand(options) {
|
|
3648
6627
|
const rootDir = options.dir || process.cwd();
|
|
3649
6628
|
const deep = options.deep ?? false;
|
|
6629
|
+
const staticOnly = options.staticOnly ?? false;
|
|
3650
6630
|
const format = options.format || "terminal";
|
|
6631
|
+
const startTime = Date.now();
|
|
3651
6632
|
console.log();
|
|
3652
6633
|
console.log(
|
|
3653
|
-
chalk3.bold(" Forvibe CLI") + chalk3.gray("
|
|
6634
|
+
chalk3.bold(" Forvibe CLI") + chalk3.gray(" - App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "") + (staticOnly ? chalk3.cyan(" [STATIC ONLY]") : "")
|
|
3654
6635
|
);
|
|
3655
6636
|
console.log();
|
|
3656
|
-
const availableProviders = getAvailableProviders(deep);
|
|
3657
|
-
if (availableProviders.length === 0) {
|
|
3658
|
-
console.log(chalk3.red(" \u2717 No AI API key found. Set one of the following:\n"));
|
|
3659
|
-
console.log(
|
|
3660
|
-
chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
|
|
3661
|
-
);
|
|
3662
|
-
console.log(
|
|
3663
|
-
chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
|
|
3664
|
-
);
|
|
3665
|
-
console.log(
|
|
3666
|
-
chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
|
|
3667
|
-
);
|
|
3668
|
-
console.log();
|
|
3669
|
-
console.log(
|
|
3670
|
-
chalk3.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n")
|
|
3671
|
-
);
|
|
3672
|
-
process.exit(1);
|
|
3673
|
-
}
|
|
3674
|
-
let provider;
|
|
3675
|
-
if (availableProviders.length === 1) {
|
|
3676
|
-
provider = availableProviders[0].create();
|
|
3677
|
-
} else {
|
|
3678
|
-
console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
|
|
3679
|
-
availableProviders.forEach((p, i) => {
|
|
3680
|
-
const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
|
|
3681
|
-
console.log(` ${label}`);
|
|
3682
|
-
});
|
|
3683
|
-
console.log();
|
|
3684
|
-
const answer = await askQuestion2(
|
|
3685
|
-
chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
|
|
3686
|
-
);
|
|
3687
|
-
const index = parseInt(answer, 10) - 1;
|
|
3688
|
-
if (isNaN(index) || index < 0 || index >= availableProviders.length) {
|
|
3689
|
-
const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
|
|
3690
|
-
provider = recommended.create();
|
|
3691
|
-
console.log(chalk3.gray(` Using ${recommended.name} (default)
|
|
3692
|
-
`));
|
|
3693
|
-
} else {
|
|
3694
|
-
provider = availableProviders[index].create();
|
|
3695
|
-
}
|
|
3696
|
-
}
|
|
3697
|
-
console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
|
|
3698
|
-
console.log();
|
|
3699
6637
|
const techSpinner = ora2({
|
|
3700
6638
|
text: "Detecting tech stack...",
|
|
3701
6639
|
prefixText: " "
|
|
@@ -3704,7 +6642,7 @@ async function reviewCommand(options) {
|
|
|
3704
6642
|
if (techStack.stack === "unknown") {
|
|
3705
6643
|
techSpinner.fail(
|
|
3706
6644
|
chalk3.red(
|
|
3707
|
-
"No supported project found. Supported: Swift, Flutter, React Native, Kotlin, Capacitor, .NET MAUI"
|
|
6645
|
+
"No supported project found. Supported: Swift, Objective-C, Flutter, React Native, Kotlin, Kotlin Multiplatform, Capacitor, .NET MAUI, Unity"
|
|
3708
6646
|
)
|
|
3709
6647
|
);
|
|
3710
6648
|
process.exit(1);
|
|
@@ -3722,59 +6660,199 @@ async function reviewCommand(options) {
|
|
|
3722
6660
|
configSpinner.succeed(
|
|
3723
6661
|
`Config: ${chalk3.white(appName)} ${bundleId ? chalk3.gray(`(${bundleId})`) : ""}`
|
|
3724
6662
|
);
|
|
3725
|
-
const
|
|
3726
|
-
text: "
|
|
6663
|
+
const rulepackSpinner = ora2({
|
|
6664
|
+
text: "Loading review rulepack...",
|
|
3727
6665
|
prefixText: " "
|
|
3728
6666
|
}).start();
|
|
3729
|
-
const
|
|
3730
|
-
|
|
3731
|
-
|
|
6667
|
+
const rulepackBaseUrl = options.rulepackUrl || options.apiUrl || DEFAULT_RULEPACK_BASE_URL;
|
|
6668
|
+
let loadedRulepack;
|
|
6669
|
+
try {
|
|
6670
|
+
loadedRulepack = await loadRulepackBundle({ baseUrl: rulepackBaseUrl });
|
|
6671
|
+
} catch (error) {
|
|
6672
|
+
rulepackSpinner.fail(
|
|
6673
|
+
chalk3.red(error instanceof Error ? error.message : "Failed to load rulepack")
|
|
6674
|
+
);
|
|
6675
|
+
process.exit(1);
|
|
6676
|
+
}
|
|
6677
|
+
const { bundle } = loadedRulepack;
|
|
6678
|
+
rulepackSpinner.succeed(
|
|
6679
|
+
`Rulepack ${chalk3.bold(`v${loadedRulepack.version}`)} ${chalk3.gray(`(${loadedRulepack.source})`)}`
|
|
3732
6680
|
);
|
|
3733
6681
|
const sourceSpinner = ora2({
|
|
3734
6682
|
text: "Reading source code...",
|
|
3735
6683
|
prefixText: " "
|
|
3736
6684
|
}).start();
|
|
3737
6685
|
const maxChars = deep ? 4e5 : 15e4;
|
|
3738
|
-
const
|
|
6686
|
+
const sourceFiles = readSourceFiles(rootDir, techStack.stack, maxChars);
|
|
6687
|
+
const sourceCode = joinSourceFiles(sourceFiles);
|
|
3739
6688
|
const projectTree = generateProjectTree(rootDir);
|
|
3740
6689
|
const sourceLines = sourceCode.split("\n").length;
|
|
3741
6690
|
sourceSpinner.succeed(
|
|
3742
|
-
`Source code: ${chalk3.bold(String(sourceLines))} lines
|
|
6691
|
+
`Source code: ${chalk3.bold(String(sourceLines))} lines in ${chalk3.bold(String(sourceFiles.length))} files`
|
|
3743
6692
|
);
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
text: deep ? `Running deep review with ${provider.name} (Pass 1: feature detection, Pass 2: Apple reviewer simulation)...` : `Running review with ${provider.name} (simulating Apple's review process)...`,
|
|
6693
|
+
const staticSpinner = ora2({
|
|
6694
|
+
text: "Running static rule engine...",
|
|
3747
6695
|
prefixText: " "
|
|
3748
6696
|
}).start();
|
|
6697
|
+
const staticResult = runStaticEngine({
|
|
6698
|
+
rootDir,
|
|
6699
|
+
stackResult: techStack,
|
|
6700
|
+
appConfig: {
|
|
6701
|
+
app_name: config.app_name,
|
|
6702
|
+
bundle_id: config.bundle_id,
|
|
6703
|
+
version: config.version,
|
|
6704
|
+
min_ios_version: config.min_ios_version,
|
|
6705
|
+
min_android_sdk: config.min_android_sdk
|
|
6706
|
+
},
|
|
6707
|
+
bundle,
|
|
6708
|
+
sourceFiles
|
|
6709
|
+
});
|
|
6710
|
+
const checkCount = (status) => staticResult.checks.filter((c) => c.status === status).length;
|
|
6711
|
+
staticSpinner.succeed(
|
|
6712
|
+
`Static scan: ${chalk3.red(`${checkCount("fail")} failed`)}, ${chalk3.yellow(`${checkCount("unverified")} unverified`)}, ${chalk3.green(`${checkCount("pass")} passed`)}, ${chalk3.gray(`${checkCount("na")} n/a`)}`
|
|
6713
|
+
);
|
|
6714
|
+
let provider = null;
|
|
6715
|
+
if (!staticOnly) {
|
|
6716
|
+
const availableProviders = getAvailableProviders(deep);
|
|
6717
|
+
if (availableProviders.length === 0) {
|
|
6718
|
+
console.log();
|
|
6719
|
+
console.log(
|
|
6720
|
+
chalk3.yellow(
|
|
6721
|
+
" No AI API key found. AI behavioral review skipped. Static rule results below."
|
|
6722
|
+
)
|
|
6723
|
+
);
|
|
6724
|
+
console.log(chalk3.gray(" To enable the AI review pass, set one of the following:"));
|
|
6725
|
+
console.log(
|
|
6726
|
+
chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
|
|
6727
|
+
);
|
|
6728
|
+
console.log(
|
|
6729
|
+
chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
|
|
6730
|
+
);
|
|
6731
|
+
console.log(
|
|
6732
|
+
chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
|
|
6733
|
+
);
|
|
6734
|
+
console.log();
|
|
6735
|
+
} else if (availableProviders.length === 1) {
|
|
6736
|
+
provider = availableProviders[0].create();
|
|
6737
|
+
} else {
|
|
6738
|
+
console.log();
|
|
6739
|
+
console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
|
|
6740
|
+
availableProviders.forEach((p, i) => {
|
|
6741
|
+
const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
|
|
6742
|
+
console.log(` ${label}`);
|
|
6743
|
+
});
|
|
6744
|
+
console.log();
|
|
6745
|
+
const answer = await askQuestion2(
|
|
6746
|
+
chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
|
|
6747
|
+
);
|
|
6748
|
+
const index = parseInt(answer, 10) - 1;
|
|
6749
|
+
if (isNaN(index) || index < 0 || index >= availableProviders.length) {
|
|
6750
|
+
const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
|
|
6751
|
+
provider = recommended.create();
|
|
6752
|
+
console.log(chalk3.gray(` Using ${recommended.name} (default)
|
|
6753
|
+
`));
|
|
6754
|
+
} else {
|
|
6755
|
+
provider = availableProviders[index].create();
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
if (provider) {
|
|
6759
|
+
console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
|
|
6760
|
+
console.log();
|
|
6761
|
+
}
|
|
6762
|
+
}
|
|
3749
6763
|
let report;
|
|
3750
|
-
|
|
6764
|
+
if (!provider) {
|
|
6765
|
+
report = assembleReportV2({
|
|
6766
|
+
platform: techStack.label,
|
|
6767
|
+
bundleId: config.bundle_id,
|
|
6768
|
+
sourceFilesAnalyzed: sourceFiles.length,
|
|
6769
|
+
featuresDetected: [],
|
|
6770
|
+
staticFindings: staticResult.findings,
|
|
6771
|
+
aiFindings: [],
|
|
6772
|
+
checks: staticResult.checks,
|
|
6773
|
+
profile: staticResult.profile,
|
|
6774
|
+
rulepack: {
|
|
6775
|
+
version: loadedRulepack.version,
|
|
6776
|
+
store: bundle.store,
|
|
6777
|
+
source: loadedRulepack.source
|
|
6778
|
+
},
|
|
6779
|
+
engineVersion: getCliVersion(),
|
|
6780
|
+
aiReviewRan: false,
|
|
6781
|
+
ragStoriesUsed: 0,
|
|
6782
|
+
scanDurationMs: Date.now() - startTime
|
|
6783
|
+
});
|
|
6784
|
+
} else {
|
|
3751
6785
|
const enrichedSource = buildEnrichedContext(
|
|
3752
6786
|
sourceCode,
|
|
3753
6787
|
projectTree,
|
|
3754
|
-
|
|
3755
|
-
config
|
|
3756
|
-
);
|
|
3757
|
-
report = await runCodeReview(
|
|
3758
|
-
provider,
|
|
3759
|
-
techStack.label,
|
|
3760
|
-
appName,
|
|
3761
|
-
bundleId,
|
|
3762
|
-
enrichedSource,
|
|
3763
|
-
sourceLines,
|
|
3764
|
-
deep
|
|
3765
|
-
);
|
|
3766
|
-
reviewSpinner.succeed(
|
|
3767
|
-
chalk3.green(
|
|
3768
|
-
`Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
|
|
3769
|
-
)
|
|
3770
|
-
);
|
|
3771
|
-
} catch (error) {
|
|
3772
|
-
reviewSpinner.fail(
|
|
3773
|
-
chalk3.red(
|
|
3774
|
-
`Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3775
|
-
)
|
|
6788
|
+
staticResult.profile.sdks
|
|
3776
6789
|
);
|
|
3777
|
-
|
|
6790
|
+
const { text: redactedSource, redactedCount } = redactSecrets(enrichedSource);
|
|
6791
|
+
if (redactedCount > 0) {
|
|
6792
|
+
console.log(
|
|
6793
|
+
chalk3.yellow(
|
|
6794
|
+
` Redacted ${redactedCount} secret-like values before sending source to the AI provider.`
|
|
6795
|
+
)
|
|
6796
|
+
);
|
|
6797
|
+
console.log();
|
|
6798
|
+
}
|
|
6799
|
+
const reviewSpinner = ora2({
|
|
6800
|
+
text: deep ? `Running deep review with ${provider.name} (Pass 1: feature detection, Pass 2: Apple reviewer simulation)...` : `Running review with ${provider.name} (simulating Apple's review process)...`,
|
|
6801
|
+
prefixText: " "
|
|
6802
|
+
}).start();
|
|
6803
|
+
try {
|
|
6804
|
+
const aiResult = await runCodeReview({
|
|
6805
|
+
provider,
|
|
6806
|
+
platform: techStack.label,
|
|
6807
|
+
appName,
|
|
6808
|
+
bundleId,
|
|
6809
|
+
sourceContext: redactedSource,
|
|
6810
|
+
deep,
|
|
6811
|
+
profile: staticResult.profile,
|
|
6812
|
+
staticChecks: staticResult.checks,
|
|
6813
|
+
staticFindings: staticResult.findings,
|
|
6814
|
+
bundle
|
|
6815
|
+
});
|
|
6816
|
+
report = assembleReportV2({
|
|
6817
|
+
platform: techStack.label,
|
|
6818
|
+
bundleId: config.bundle_id,
|
|
6819
|
+
sourceFilesAnalyzed: sourceFiles.length,
|
|
6820
|
+
featuresDetected: aiResult.featuresDetected,
|
|
6821
|
+
staticFindings: aiResult.staticFindings,
|
|
6822
|
+
aiFindings: aiResult.aiFindings,
|
|
6823
|
+
checks: aiResult.checks,
|
|
6824
|
+
profile: aiResult.profile,
|
|
6825
|
+
rulepack: {
|
|
6826
|
+
version: loadedRulepack.version,
|
|
6827
|
+
store: bundle.store,
|
|
6828
|
+
source: loadedRulepack.source
|
|
6829
|
+
},
|
|
6830
|
+
engineVersion: getCliVersion(),
|
|
6831
|
+
aiReviewRan: true,
|
|
6832
|
+
aiAssessment: aiResult.aiAssessment,
|
|
6833
|
+
ragStoriesUsed: aiResult.ragStoriesUsed,
|
|
6834
|
+
scanDurationMs: Date.now() - startTime
|
|
6835
|
+
});
|
|
6836
|
+
reviewSpinner.succeed(
|
|
6837
|
+
chalk3.green(
|
|
6838
|
+
`Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
|
|
6839
|
+
)
|
|
6840
|
+
);
|
|
6841
|
+
if (aiResult.dedupedCount > 0) {
|
|
6842
|
+
console.log(
|
|
6843
|
+
chalk3.gray(
|
|
6844
|
+
` Deduplicated ${aiResult.dedupedCount} findings already covered by static analysis.`
|
|
6845
|
+
)
|
|
6846
|
+
);
|
|
6847
|
+
}
|
|
6848
|
+
} catch (error) {
|
|
6849
|
+
reviewSpinner.fail(
|
|
6850
|
+
chalk3.red(
|
|
6851
|
+
`Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
6852
|
+
)
|
|
6853
|
+
);
|
|
6854
|
+
process.exit(1);
|
|
6855
|
+
}
|
|
3778
6856
|
}
|
|
3779
6857
|
switch (format) {
|
|
3780
6858
|
case "json":
|
|
@@ -3839,14 +6917,16 @@ async function reviewCommand(options) {
|
|
|
3839
6917
|
}
|
|
3840
6918
|
}
|
|
3841
6919
|
}
|
|
3842
|
-
function buildEnrichedContext(sourceCode, projectTree,
|
|
6920
|
+
function buildEnrichedContext(sourceCode, projectTree, sdks) {
|
|
3843
6921
|
const sections = [];
|
|
3844
6922
|
sections.push(`## Project Structure
|
|
3845
6923
|
\`\`\`
|
|
3846
6924
|
${projectTree}
|
|
3847
6925
|
\`\`\``);
|
|
3848
|
-
if (
|
|
3849
|
-
const sdkList =
|
|
6926
|
+
if (sdks.length > 0) {
|
|
6927
|
+
const sdkList = sdks.map(
|
|
6928
|
+
(sdk) => `- ${sdk.name} (${sdk.category})${sdk.data_collection.length > 0 ? `, collects: ${sdk.data_collection.join(", ")}` : ""}`
|
|
6929
|
+
).join("\n");
|
|
3850
6930
|
sections.push(`## Detected SDKs
|
|
3851
6931
|
${sdkList}`);
|
|
3852
6932
|
}
|
|
@@ -3858,7 +6938,7 @@ ${sourceCode}`);
|
|
|
3858
6938
|
|
|
3859
6939
|
// src/index.ts
|
|
3860
6940
|
var program = new Command();
|
|
3861
|
-
program.name("forvibe").description("Forvibe CLI
|
|
6941
|
+
program.name("forvibe").description("Forvibe CLI - AI-powered App Store automation").version(getCliVersion());
|
|
3862
6942
|
program.command("analyze", { isDefault: true }).description(
|
|
3863
6943
|
"Analyze your project and send the report to Forvibe for automated App Store setup"
|
|
3864
6944
|
).option("-d, --dir <path>", "Project directory to analyze", process.cwd()).option(
|
|
@@ -3868,9 +6948,13 @@ program.command("analyze", { isDefault: true }).description(
|
|
|
3868
6948
|
).action(analyzeCommand);
|
|
3869
6949
|
program.command("review").description(
|
|
3870
6950
|
"Simulate Apple's App Store review process on your project"
|
|
3871
|
-
).option("-d, --dir <path>", "Project directory to review", process.cwd()).option("--deep", "Enable deep 2-pass review (feature detection + reviewer simulation)").option("--format <type>", "Output format: terminal, json, markdown", "terminal").option("--send", "Send results to Forvibe for combined review with metadata analysis").option(
|
|
6951
|
+
).option("-d, --dir <path>", "Project directory to review", process.cwd()).option("--deep", "Enable deep 2-pass review (feature detection + reviewer simulation)").option("--format <type>", "Output format: terminal, json, markdown", "terminal").option("--static-only", "Run only the static rule engine, skipping the AI behavioral review (no API key needed)").option("--send", "Send results to Forvibe for combined review with metadata analysis").option(
|
|
3872
6952
|
"--api-url <url>",
|
|
3873
6953
|
"Forvibe API URL (for development)",
|
|
3874
6954
|
void 0
|
|
6955
|
+
).option(
|
|
6956
|
+
"--rulepack-url <url>",
|
|
6957
|
+
"Base URL to fetch the review rulepack from (defaults to the Forvibe API URL)",
|
|
6958
|
+
void 0
|
|
3875
6959
|
).action(reviewCommand);
|
|
3876
6960
|
program.parse();
|