@forvibe/cli 1.1.0 → 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/index.js CHANGED
@@ -5,12 +5,14 @@ import { Command } from "commander";
5
5
 
6
6
  // src/commands/analyze.ts
7
7
  import { createInterface } from "readline";
8
+ import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
9
+ import { basename as basename3, extname as extname4, relative as relative5 } from "path";
8
10
  import chalk from "chalk";
9
11
  import ora from "ora";
10
12
 
11
13
  // src/analyzers/tech-detector.ts
12
14
  import { existsSync as existsSync2, readdirSync as readdirSync2 } from "fs";
13
- import { join as join2 } from "path";
15
+ import { join as join2, relative as relative2 } from "path";
14
16
 
15
17
  // src/utils/file-scanner.ts
16
18
  import { readFileSync, readdirSync, statSync, existsSync } from "fs";
@@ -137,8 +139,22 @@ function readFileSafe(filePath) {
137
139
  return null;
138
140
  }
139
141
  }
142
+ function fileExists(filePath) {
143
+ return existsSync(filePath);
144
+ }
140
145
 
141
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
+ }
142
158
  var DETECTION_RULES = [
143
159
  {
144
160
  stack: "flutter",
@@ -153,6 +169,45 @@ var DETECTION_RULES = [
153
169
  return files.length >= 2 ? files : [];
154
170
  }
155
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
+ },
156
211
  {
157
212
  stack: "react-native",
158
213
  label: "React Native",
@@ -196,7 +251,7 @@ var DETECTION_RULES = [
196
251
  },
197
252
  {
198
253
  stack: "swift",
199
- label: "Swift / SwiftUI",
254
+ label: "iOS Native (Swift/Objective-C)",
200
255
  platforms: ["ios"],
201
256
  detect: (dir) => {
202
257
  const files = [];
@@ -213,7 +268,7 @@ var DETECTION_RULES = [
213
268
  try {
214
269
  const entries = readdirSync2(dir);
215
270
  for (const entry of entries) {
216
- if (entry.endsWith(".swift")) {
271
+ if (entry.endsWith(".swift") || entry.endsWith(".m") || entry.endsWith(".mm")) {
217
272
  files.push(entry);
218
273
  break;
219
274
  }
@@ -223,6 +278,37 @@ var DETECTION_RULES = [
223
278
  return files;
224
279
  }
225
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
+ },
226
312
  {
227
313
  stack: "kotlin",
228
314
  label: "Android (Kotlin)",
@@ -254,7 +340,9 @@ var DETECTION_RULES = [
254
340
  for (const entry of entries) {
255
341
  if (entry.endsWith(".csproj")) {
256
342
  const content = readFileSafe(join2(dir, entry));
257
- if (content && (content.includes("Maui") || content.includes("MAUI"))) {
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) {
258
346
  files.push(entry);
259
347
  }
260
348
  }
@@ -262,6 +350,7 @@ var DETECTION_RULES = [
262
350
  } catch {
263
351
  }
264
352
  if (existsSync2(join2(dir, "Platforms"))) files.push("Platforms/");
353
+ if (existsSync2(join2(dir, "Resources"))) files.push("Resources/");
265
354
  return files.length >= 1 ? files : [];
266
355
  }
267
356
  }
@@ -295,17 +384,22 @@ function parseConfig(rootDir, techStack) {
295
384
  switch (techStack) {
296
385
  case "flutter":
297
386
  return parseFlutterConfig(rootDir);
387
+ case "expo":
388
+ return parseExpoConfig(rootDir);
298
389
  case "react-native":
299
390
  return parseReactNativeConfig(rootDir);
300
391
  case "swift":
301
392
  return parseSwiftConfig(rootDir);
302
393
  case "kotlin":
303
- case "java":
304
394
  return parseAndroidConfig(rootDir);
305
395
  case "capacitor":
306
396
  return parseCapacitorConfig(rootDir);
307
397
  case "dotnet-maui":
308
398
  return parseMauiConfig(rootDir);
399
+ case "unity":
400
+ return parseUnityConfig(rootDir);
401
+ case "kmp":
402
+ return parseKmpConfig(rootDir);
309
403
  default:
310
404
  return emptyConfig();
311
405
  }
@@ -368,6 +462,82 @@ function parseFlutterConfig(rootDir) {
368
462
  }
369
463
  return config;
370
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
+ }
371
541
  function parseReactNativeConfig(rootDir) {
372
542
  const config = emptyConfig();
373
543
  const appJsonContent = readFileSafe(join3(rootDir, "app.json"));
@@ -378,6 +548,17 @@ function parseReactNativeConfig(rootDir) {
378
548
  } catch {
379
549
  }
380
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
+ }
381
562
  const pkgContent = readFileSafe(join3(rootDir, "package.json"));
382
563
  if (pkgContent) {
383
564
  try {
@@ -394,6 +575,12 @@ function parseReactNativeConfig(rootDir) {
394
575
  if (plistData) {
395
576
  config.bundle_id = plistData.CFBundleIdentifier || null;
396
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
+ }
397
584
  }
398
585
  }
399
586
  if (!config.bundle_id || config.bundle_id.includes("$(")) {
@@ -418,6 +605,9 @@ function parseReactNativeConfig(rootDir) {
418
605
  }
419
606
  config.min_android_sdk = gradleConfig.minSdk || null;
420
607
  }
608
+ if (!config.app_name) {
609
+ config.app_name = readAndroidAppName(join3(rootDir, "android"));
610
+ }
421
611
  return config;
422
612
  }
423
613
  var EXTENSION_SUFFIXES = [
@@ -560,34 +750,26 @@ function parseSwiftConfig(rootDir) {
560
750
  }
561
751
  function parseAndroidConfig(rootDir) {
562
752
  const config = emptyConfig();
563
- const buildGradlePath = findFile(join3(rootDir, "app"), "build.gradle", 2) || findFile(join3(rootDir, "app"), "build.gradle.kts", 2) || findFile(rootDir, "build.gradle", 1) || findFile(rootDir, "build.gradle.kts", 1);
564
- if (buildGradlePath) {
565
- const gradleConfig = parseGradle(buildGradlePath);
566
- config.bundle_id = gradleConfig.applicationId || null;
567
- config.version = gradleConfig.versionName || null;
568
- config.min_android_sdk = gradleConfig.minSdk || null;
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);
569
763
  }
570
- const manifestPath = findFile(rootDir, "AndroidManifest.xml", 6);
571
- if (manifestPath) {
572
- const content = readFileSafe(manifestPath);
573
- if (content) {
574
- const labelMatch = content.match(/android:label="([^"]+)"/);
575
- if (labelMatch && !labelMatch[1].startsWith("@")) {
576
- config.app_name = labelMatch[1];
577
- }
578
- }
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;
579
770
  }
580
771
  if (!config.app_name) {
581
- const stringsPath = findFile(rootDir, "strings.xml", 8);
582
- if (stringsPath) {
583
- const content = readFileSafe(stringsPath);
584
- if (content) {
585
- const nameMatch = content.match(
586
- /<string name="app_name">(.*?)<\/string>/
587
- );
588
- if (nameMatch) config.app_name = nameMatch[1];
589
- }
590
- }
772
+ config.app_name = readAndroidAppName(rootDir);
591
773
  }
592
774
  return config;
593
775
  }
@@ -655,6 +837,58 @@ function parseMauiConfig(rootDir) {
655
837
  }
656
838
  return config;
657
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
+ }
658
892
  function parsePlist(filePath) {
659
893
  const content = readFileSafe(filePath);
660
894
  if (!content) return null;
@@ -664,6 +898,38 @@ function parsePlist(filePath) {
664
898
  return null;
665
899
  }
666
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
+ }
667
933
  function parseGradle(filePath) {
668
934
  const content = readFileSafe(filePath);
669
935
  if (!content) return { applicationId: null, versionName: null, minSdk: null };
@@ -685,6 +951,7 @@ function parseGradle(filePath) {
685
951
 
686
952
  // src/analyzers/sdk-scanner.ts
687
953
  import { join as join4 } from "path";
954
+ import { readdirSync as readdirSync4 } from "fs";
688
955
  import YAML2 from "yaml";
689
956
  var SDK_MAP = {
690
957
  // Analytics
@@ -789,6 +1056,45 @@ var SDK_MAP = {
789
1056
  ARKit: { category: "other", collects: [] },
790
1057
  RealityKit: { category: "other", collects: [] },
791
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"] },
792
1098
  // --- Native Android frameworks (detected via `import` in Kotlin/Java source) ---
793
1099
  "com.google.android.gms.ads": { category: "ads", collects: ["device_info"], advertising: "personalized" },
794
1100
  "com.google.android.gms.maps": { category: "other", collects: ["location"] },
@@ -797,8 +1103,123 @@ var SDK_MAP = {
797
1103
  "com.android.billingclient": { category: "payment", collects: ["financial_info"] },
798
1104
  "androidx.health": { category: "other", collects: ["health_data"] },
799
1105
  "androidx.biometric": { category: "auth", collects: ["biometric_data"] },
800
- "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"] }
801
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
+ }
802
1223
  function scanSDKs(rootDir, techStack) {
803
1224
  const detectedSDKs = [];
804
1225
  const dataCollected = /* @__PURE__ */ new Set();
@@ -865,6 +1286,7 @@ function getDependencies(rootDir, techStack) {
865
1286
  }
866
1287
  break;
867
1288
  }
1289
+ case "expo":
868
1290
  case "react-native":
869
1291
  case "capacitor": {
870
1292
  const pkgContent = readFileSafe(join4(rootDir, "package.json"));
@@ -878,6 +1300,30 @@ function getDependencies(rootDir, techStack) {
878
1300
  }
879
1301
  break;
880
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
+ }
881
1327
  case "swift": {
882
1328
  const podfileContent = readFileSafe(join4(rootDir, "Podfile"));
883
1329
  if (podfileContent) {
@@ -925,29 +1371,51 @@ function getDependencies(rootDir, techStack) {
925
1371
  deps.push(...nativeImports);
926
1372
  break;
927
1373
  }
928
- case "kotlin":
929
- case "java": {
930
- const gradlePaths = [
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([
931
1384
  join4(rootDir, "app/build.gradle"),
932
1385
  join4(rootDir, "app/build.gradle.kts"),
933
1386
  join4(rootDir, "build.gradle"),
934
1387
  join4(rootDir, "build.gradle.kts")
935
- ];
936
- for (const path2 of gradlePaths) {
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) {
937
1404
  const content = readFileSafe(path2);
938
- if (content) {
939
- const implMatches = content.matchAll(
940
- /implementation\s*\(?['"]([^'"]+)['"]\)?/g
941
- );
942
- for (const match of implMatches) {
943
- const parts = match[1].split(":");
944
- if (parts.length >= 2) {
945
- deps.push(parts[1]);
946
- }
947
- }
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);
948
1413
  }
949
1414
  }
950
- const androidFiles = findFiles(join4(rootDir, "app/src"), [".kt", ".java"], 8);
1415
+ const androidFiles = [
1416
+ ...findFiles(join4(rootDir, "app/src"), [".kt", ".java"], 8),
1417
+ ...findFiles(rootDir, [".kt", ".java"], 6)
1418
+ ];
951
1419
  const androidNativeImports = /* @__PURE__ */ new Set();
952
1420
  const androidFrameworkPrefixes = [
953
1421
  "com.google.android.gms.ads",
@@ -957,16 +1425,20 @@ function getDependencies(rootDir, techStack) {
957
1425
  "com.android.billingclient",
958
1426
  "androidx.health",
959
1427
  "androidx.biometric",
960
- "androidx.camera"
1428
+ "androidx.camera",
1429
+ "androidx.compose",
1430
+ "androidx.navigation.compose"
961
1431
  ];
962
- for (const file of androidFiles.slice(0, 100)) {
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;
963
1437
  const content = readFileSafe(file);
964
1438
  if (!content) continue;
965
1439
  for (const prefix of androidFrameworkPrefixes) {
966
- if (content.includes(`import ${prefix}`)) {
967
- if (!androidNativeImports.has(prefix)) {
968
- androidNativeImports.add(prefix);
969
- }
1440
+ if (content.includes(`import ${prefix}`) && !androidNativeImports.has(prefix)) {
1441
+ androidNativeImports.add(prefix);
970
1442
  }
971
1443
  }
972
1444
  }
@@ -976,9 +1448,262 @@ function getDependencies(rootDir, techStack) {
976
1448
  }
977
1449
  return deps;
978
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
+ }
979
1704
 
980
1705
  // src/analyzers/branding.ts
981
- import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync as readdirSync4, statSync as statSync2 } from "fs";
1706
+ import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
982
1707
  import { join as join5, extname } from "path";
983
1708
  function extractBranding(rootDir, techStack) {
984
1709
  const result = {
@@ -1001,20 +1726,129 @@ function extractColors(rootDir, techStack) {
1001
1726
  switch (techStack) {
1002
1727
  case "flutter":
1003
1728
  return extractFlutterColors(rootDir);
1729
+ case "expo":
1730
+ return extractExpoColors(rootDir);
1004
1731
  case "react-native":
1005
1732
  case "capacitor":
1006
1733
  return extractJSColors(rootDir);
1007
1734
  case "swift":
1008
1735
  return extractSwiftColors(rootDir);
1009
1736
  case "kotlin":
1010
- case "java":
1011
1737
  return extractAndroidColors(rootDir);
1738
+ case "dotnet-maui":
1739
+ return extractMauiColors(rootDir);
1012
1740
  default:
1013
1741
  return { primary: null, secondary: null, allColors: [] };
1014
1742
  }
1015
1743
  }
1016
- function isIrrelevantColor(hex) {
1017
- const r = parseInt(hex.slice(1, 3), 16);
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);
1018
1852
  const g = parseInt(hex.slice(3, 5), 16);
1019
1853
  const b = parseInt(hex.slice(5, 7), 16);
1020
1854
  const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
@@ -1130,7 +1964,7 @@ function extractSwiftColors(rootDir) {
1130
1964
  }
1131
1965
  for (const catalog of assetsCatalogs) {
1132
1966
  try {
1133
- const entries = readdirSync4(catalog);
1967
+ const entries = readdirSync5(catalog);
1134
1968
  for (const entry of entries) {
1135
1969
  if (entry.endsWith(".colorset") && entry !== "AccentColor.colorset") {
1136
1970
  const color = parseColorSet(join5(catalog, entry));
@@ -1179,51 +2013,103 @@ function extractSwiftColors(rootDir) {
1179
2013
  }
1180
2014
  function extractAndroidColors(rootDir) {
1181
2015
  const hexColors = [];
1182
- const colorFiles = ["colors.xml", "themes.xml", "styles.xml"];
1183
- for (const fileName of colorFiles) {
1184
- for (const resDir of ["app/src/main/res/values", "src/main/res/values"]) {
1185
- const filePath = join5(rootDir, resDir, fileName);
1186
- const content = readFileSafe(filePath);
1187
- if (!content) continue;
1188
- const colorMatches = content.matchAll(
1189
- /<color\s+name="([^"]*)">\s*#([0-9a-fA-F]{6,8})\s*<\/color>/g
1190
- );
1191
- for (const match of colorMatches) {
1192
- const name = match[1].toLowerCase();
1193
- let hex = match[2];
1194
- if (hex.length === 8) hex = hex.substring(2);
1195
- const color = `#${hex}`;
1196
- if (name.includes("primary") && !name.includes("variant") && !name.includes("dark")) {
1197
- hexColors.unshift(color);
1198
- } else if (name.includes("secondary") || name.includes("accent")) {
1199
- hexColors.push(color);
1200
- } else {
1201
- hexColors.push(color);
1202
- }
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;
1203
2039
  }
1204
- const themeColorMatches = content.matchAll(
1205
- /<item\s+name="(?:color|android:color)([^"]*)">\s*@color\/([^<]+)\s*<\/item>/g
1206
- );
1207
- for (const match of themeColorMatches) {
1208
- if (match[1].toLowerCase().includes("primary")) {
1209
- }
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;
1210
2063
  }
2064
+ hexColors.push(hex);
1211
2065
  }
1212
2066
  }
1213
2067
  const ktFiles = findFiles(rootDir, [".kt"], 6);
1214
- for (const file of ktFiles.slice(0, 50)) {
2068
+ for (const file of ktFiles.slice(0, 80)) {
1215
2069
  const fileName = file.toLowerCase();
1216
2070
  if (!fileName.includes("color") && !fileName.includes("theme")) continue;
1217
2071
  const content = readFileSafe(file);
1218
2072
  if (!content) continue;
1219
- const colorMatches = content.matchAll(/Color\s*\(\s*0[xX]([0-9a-fA-F]{8})\s*\)/g);
1220
- for (const match of colorMatches) {
1221
- hexColors.push(`#${match[1].substring(2)}`);
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
+ }
1222
2108
  }
1223
2109
  }
1224
2110
  return {
1225
- primary: hexColors.length > 0 ? hexColors[0] : null,
1226
- secondary: hexColors.length > 1 ? hexColors[1] : null,
2111
+ primary: primary || hexColors[0] || null,
2112
+ secondary: secondary || hexColors[1] || null,
1227
2113
  allColors: hexColors
1228
2114
  };
1229
2115
  }
@@ -1232,7 +2118,7 @@ function findAssetsCatalogs(rootDir) {
1232
2118
  function search(dir, depth) {
1233
2119
  if (depth > 5) return;
1234
2120
  try {
1235
- const entries = readdirSync4(dir);
2121
+ const entries = readdirSync5(dir);
1236
2122
  for (const entry of entries) {
1237
2123
  if (entry.startsWith(".") || entry === "Pods" || entry === "DerivedData") continue;
1238
2124
  const fullPath = join5(dir, entry);
@@ -1298,6 +2184,18 @@ function findAppIcon(rootDir, techStack) {
1298
2184
  join5(rootDir, "android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png")
1299
2185
  );
1300
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;
1301
2199
  case "react-native":
1302
2200
  case "capacitor":
1303
2201
  iconPaths.push(
@@ -1313,13 +2211,47 @@ function findAppIcon(rootDir, techStack) {
1313
2211
  ...findIconsInAppIconSet(rootDir)
1314
2212
  );
1315
2213
  break;
1316
- case "kotlin":
1317
- case "java":
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":
1318
2247
  iconPaths.push(
1319
- join5(rootDir, "app/src/main/res/mipmap-xxxhdpi/ic_launcher.png"),
1320
- join5(rootDir, "app/src/main/res/mipmap-xxhdpi/ic_launcher.png"),
1321
- join5(rootDir, "app/src/main/res/mipmap-xhdpi/ic_launcher.png"),
1322
- join5(rootDir, "app/src/main/ic_launcher-playstore.png")
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")
1323
2255
  );
1324
2256
  break;
1325
2257
  }
@@ -1331,7 +2263,7 @@ function findAppIcon(rootDir, techStack) {
1331
2263
  if (stat.size < 100) continue;
1332
2264
  const buffer = readFileSync2(iconPath);
1333
2265
  const ext = extname(iconPath).toLowerCase();
1334
- 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";
1335
2267
  const base64 = `data:${mime};base64,${buffer.toString("base64")}`;
1336
2268
  return { base64, path: iconPath };
1337
2269
  } catch {
@@ -1341,12 +2273,33 @@ function findAppIcon(rootDir, techStack) {
1341
2273
  }
1342
2274
  return { base64: null, path: null };
1343
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
+ }
1344
2297
  function findIconsInAppIconSet(searchDir) {
1345
2298
  const icons = [];
1346
2299
  function searchRecursive(dir, depth) {
1347
2300
  if (depth > 6) return;
1348
2301
  try {
1349
- const entries = readdirSync4(dir);
2302
+ const entries = readdirSync5(dir);
1350
2303
  for (const entry of entries) {
1351
2304
  const fullPath = join5(dir, entry);
1352
2305
  if (entry === "AppIcon.appiconset") {
@@ -1370,7 +2323,7 @@ function findIconsInAppIconSet(searchDir) {
1370
2323
  }
1371
2324
  }
1372
2325
  try {
1373
- const iconEntries = readdirSync4(fullPath);
2326
+ const iconEntries = readdirSync5(fullPath);
1374
2327
  for (const ie of iconEntries) {
1375
2328
  if (ie.endsWith(".png")) {
1376
2329
  icons.push(join5(fullPath, ie));
@@ -1398,7 +2351,7 @@ function findIconsInAppIconSet(searchDir) {
1398
2351
  }
1399
2352
 
1400
2353
  // src/analyzers/font-detector.ts
1401
- import { existsSync as existsSync4, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
2354
+ import { existsSync as existsSync4, readdirSync as readdirSync6, statSync as statSync3 } from "fs";
1402
2355
  import { join as join6, extname as extname2, basename } from "path";
1403
2356
  import yaml from "yaml";
1404
2357
  import plist2 from "plist";
@@ -1450,7 +2403,7 @@ function scanForFontFiles(dir, maxDepth = 3) {
1450
2403
  function walk(d, depth) {
1451
2404
  if (depth > maxDepth) return;
1452
2405
  try {
1453
- const entries = readdirSync5(d);
2406
+ const entries = readdirSync6(d);
1454
2407
  for (const entry of entries) {
1455
2408
  if (entry.startsWith(".")) continue;
1456
2409
  const fullPath = join6(d, entry);
@@ -1588,7 +2541,7 @@ function findPlistFiles(rootDir) {
1588
2541
  function search(dir, depth) {
1589
2542
  if (depth > 4) return;
1590
2543
  try {
1591
- const entries = readdirSync5(dir);
2544
+ const entries = readdirSync6(dir);
1592
2545
  for (const entry of entries) {
1593
2546
  if (entry.startsWith(".") || entry === "Pods" || entry === "DerivedData" || entry === "build") continue;
1594
2547
  const fullPath = join6(dir, entry);
@@ -1644,12 +2597,111 @@ function detectAndroidFonts(rootDir) {
1644
2597
  }
1645
2598
  return fonts;
1646
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
+ }
1647
2696
  function detectFonts(rootDir, techStack) {
1648
2697
  let rawFonts;
1649
2698
  switch (techStack) {
1650
2699
  case "flutter":
1651
2700
  rawFonts = detectFlutterFonts(rootDir);
1652
2701
  break;
2702
+ case "expo":
2703
+ rawFonts = detectExpoFonts(rootDir);
2704
+ break;
1653
2705
  case "react-native":
1654
2706
  case "capacitor":
1655
2707
  rawFonts = detectJSFonts(rootDir);
@@ -1658,9 +2710,11 @@ function detectFonts(rootDir, techStack) {
1658
2710
  rawFonts = detectSwiftFonts(rootDir);
1659
2711
  break;
1660
2712
  case "kotlin":
1661
- case "java":
1662
2713
  rawFonts = detectAndroidFonts(rootDir);
1663
2714
  break;
2715
+ case "dotnet-maui":
2716
+ rawFonts = detectMauiFonts(rootDir);
2717
+ break;
1664
2718
  default:
1665
2719
  rawFonts = [];
1666
2720
  }
@@ -1679,8 +2733,8 @@ function detectFonts(rootDir, techStack) {
1679
2733
  }
1680
2734
 
1681
2735
  // src/analyzers/source-reader.ts
1682
- import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
1683
- import { join as join7, relative as relative2 } from "path";
2736
+ import { readdirSync as readdirSync7, statSync as statSync4 } from "fs";
2737
+ import { join as join7, relative as relative3 } from "path";
1684
2738
  var README_NAMES = [
1685
2739
  "README.md",
1686
2740
  "readme.md",
@@ -1726,7 +2780,7 @@ function readReadme(rootDir) {
1726
2780
  }
1727
2781
  return null;
1728
2782
  }
1729
- function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
2783
+ function readSourceFiles(rootDir, techStack, maxTotalChars = 5e4) {
1730
2784
  const extensions = getExtensionsForStack(techStack);
1731
2785
  const priorityPatterns = getPriorityPatternsForStack(techStack);
1732
2786
  const allFiles = scanDirectory(rootDir, {
@@ -1735,7 +2789,7 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
1735
2789
  maxFiles: 500
1736
2790
  });
1737
2791
  const sorted = sortByPriority(allFiles, priorityPatterns);
1738
- const parts = [];
2792
+ const files = [];
1739
2793
  let totalChars = 0;
1740
2794
  for (const file of sorted) {
1741
2795
  if (totalChars >= maxTotalChars) break;
@@ -1744,27 +2798,36 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
1744
2798
  if (isTestFile(file)) continue;
1745
2799
  if (isGeneratedFile(file, content)) continue;
1746
2800
  const truncated = content.substring(0, 15e3);
1747
- parts.push(`--- ${file} ---
1748
- ${truncated}`);
2801
+ files.push({ path: file, content: truncated });
1749
2802
  totalChars += truncated.length;
1750
2803
  }
1751
- return parts.join("\n\n");
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));
1752
2812
  }
1753
2813
  function getExtensionsForStack(techStack) {
1754
2814
  switch (techStack) {
1755
2815
  case "flutter":
1756
2816
  return [".dart"];
1757
2817
  case "swift":
1758
- return [".swift"];
2818
+ return [".swift", ".m", ".mm", ".h"];
1759
2819
  case "kotlin":
1760
2820
  return [".kt", ".kts"];
1761
- case "java":
1762
- return [".java"];
2821
+ case "expo":
1763
2822
  case "react-native":
1764
2823
  case "capacitor":
1765
2824
  return [".ts", ".tsx", ".js", ".jsx"];
1766
2825
  case "dotnet-maui":
1767
2826
  return [".cs", ".xaml"];
2827
+ case "unity":
2828
+ return [".cs"];
2829
+ case "kmp":
2830
+ return [".kt", ".kts", ".swift"];
1768
2831
  default:
1769
2832
  return [".ts", ".js", ".swift", ".dart", ".kt"];
1770
2833
  }
@@ -1790,11 +2853,34 @@ function getPriorityPatternsForStack(techStack) {
1790
2853
  case "swift":
1791
2854
  return [...common, "view", "controller", "manager", "delegate", "contentview"];
1792
2855
  case "kotlin":
1793
- case "java":
1794
- return [...common, "activity", "fragment", "viewmodel", "repository"];
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"];
1795
2875
  case "react-native":
1796
2876
  case "capacitor":
1797
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"];
1798
2884
  default:
1799
2885
  return common;
1800
2886
  }
@@ -1817,7 +2903,14 @@ function sortByPriority(files, patterns) {
1817
2903
  }
1818
2904
  function isTestFile(file) {
1819
2905
  const lower = file.toLowerCase();
1820
- return lower.includes("test") || lower.includes("spec") || lower.includes("mock") || lower.includes("fixture") || lower.includes("__tests__");
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;
1821
2914
  }
1822
2915
  function isGeneratedFile(file, content) {
1823
2916
  const lower = file.toLowerCase();
@@ -1834,7 +2927,7 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
1834
2927
  if (depth > maxDepth || entryCount >= maxEntries) return;
1835
2928
  let entries;
1836
2929
  try {
1837
- entries = readdirSync6(dir).sort((a, b) => {
2930
+ entries = readdirSync7(dir).sort((a, b) => {
1838
2931
  const aIsDir = isDir(join7(dir, a));
1839
2932
  const bIsDir = isDir(join7(dir, b));
1840
2933
  if (aIsDir && !bIsDir) return -1;
@@ -1876,17 +2969,16 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
1876
2969
  return false;
1877
2970
  }
1878
2971
  }
1879
- const rootName = relative2(join7(rootDir, ".."), rootDir) || "project";
2972
+ const rootName = relative3(join7(rootDir, ".."), rootDir) || "project";
1880
2973
  lines.push(`${rootName}/`);
1881
2974
  walk(rootDir, "", 0);
1882
2975
  return lines.join("\n");
1883
2976
  }
1884
2977
 
1885
2978
  // src/analyzers/asset-scanner.ts
1886
- import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
1887
- import { join as join8, extname as extname3, relative as relative3, basename as basename2 } from "path";
1888
- var MAX_ASSET_SIZE = 1 * 1024 * 1024;
1889
- var MAX_TOTAL_BASE64_SIZE = 3 * 1024 * 1024;
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";
2981
+ var MAX_ASSET_SIZE = 5 * 1024 * 1024;
1890
2982
  var MAX_TOTAL_ASSETS = 10;
1891
2983
  var MAX_SCREENSHOTS = 5;
1892
2984
  var MIN_ASSET_SIZE = 500;
@@ -1916,7 +3008,7 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
1916
3008
  base64_data: buffer.toString("base64"),
1917
3009
  width: null,
1918
3010
  height: null,
1919
- source_path: relative3(rootDir, filePath)
3011
+ source_path: relative4(rootDir, filePath)
1920
3012
  };
1921
3013
  } catch {
1922
3014
  return null;
@@ -1926,7 +3018,7 @@ function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, m
1926
3018
  if (currentDepth > maxDepth) return;
1927
3019
  if (!existsSync5(dirPath)) return;
1928
3020
  try {
1929
- const entries = readdirSync7(dirPath);
3021
+ const entries = readdirSync8(dirPath);
1930
3022
  for (const entry of entries) {
1931
3023
  if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
1932
3024
  if (assets.length >= MAX_TOTAL_ASSETS) return;
@@ -1975,6 +3067,17 @@ function getScreenshotDirs(rootDir, techStack) {
1975
3067
  gen(join8(rootDir, "assets"))
1976
3068
  );
1977
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;
1978
3081
  case "react-native":
1979
3082
  case "capacitor":
1980
3083
  dirs.push(
@@ -1995,7 +3098,6 @@ function getScreenshotDirs(rootDir, techStack) {
1995
3098
  );
1996
3099
  break;
1997
3100
  case "kotlin":
1998
- case "java":
1999
3101
  dirs.push(
2000
3102
  ss(join8(rootDir, "fastlane/metadata/android/en-US/images/phoneScreenshots")),
2001
3103
  gen(join8(rootDir, "metadata/android/en-US/images")),
@@ -2003,6 +3105,15 @@ function getScreenshotDirs(rootDir, techStack) {
2003
3105
  gen(join8(rootDir, "Marketing"))
2004
3106
  );
2005
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;
2006
3117
  }
2007
3118
  return dirs;
2008
3119
  }
@@ -2029,6 +3140,31 @@ function getSplashPaths(rootDir, techStack) {
2029
3140
  );
2030
3141
  }
2031
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;
2032
3168
  case "react-native":
2033
3169
  case "capacitor":
2034
3170
  paths.push(
@@ -2039,7 +3175,6 @@ function getSplashPaths(rootDir, techStack) {
2039
3175
  );
2040
3176
  break;
2041
3177
  case "kotlin":
2042
- case "java":
2043
3178
  for (const density of ["xxxhdpi", "xxhdpi", "xhdpi"]) {
2044
3179
  paths.push(
2045
3180
  join8(rootDir, `app/src/main/res/drawable-${density}/splash.png`),
@@ -2047,6 +3182,14 @@ function getSplashPaths(rootDir, techStack) {
2047
3182
  );
2048
3183
  }
2049
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;
2050
3193
  }
2051
3194
  return paths;
2052
3195
  }
@@ -2059,19 +3202,26 @@ function getFeatureGraphicPaths(rootDir, techStack) {
2059
3202
  );
2060
3203
  switch (techStack) {
2061
3204
  case "flutter":
3205
+ case "expo":
2062
3206
  case "react-native":
2063
3207
  case "capacitor":
2064
3208
  paths.push(
2065
3209
  join8(rootDir, "assets/feature_graphic.png"),
2066
- join8(rootDir, "assets/feature-graphic.png")
3210
+ join8(rootDir, "assets/feature-graphic.png"),
3211
+ join8(rootDir, "assets/images/feature_graphic.png")
2067
3212
  );
2068
3213
  break;
2069
3214
  case "kotlin":
2070
- case "java":
2071
3215
  paths.push(
2072
3216
  join8(rootDir, "app/src/main/feature_graphic.png")
2073
3217
  );
2074
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;
2075
3225
  }
2076
3226
  return paths;
2077
3227
  }
@@ -2090,9 +3240,6 @@ function getPromotionalPaths(rootDir, techStack) {
2090
3240
  }
2091
3241
  return paths;
2092
3242
  }
2093
- function totalBase64Size(assets) {
2094
- return assets.reduce((sum, a) => sum + a.base64_data.length, 0);
2095
- }
2096
3243
  function scanAppAssets(rootDir, techStack) {
2097
3244
  const assets = [];
2098
3245
  const screenshotDirs = getScreenshotDirs(rootDir, techStack);
@@ -2131,17 +3278,7 @@ function scanAppAssets(rootDir, techStack) {
2131
3278
  break;
2132
3279
  }
2133
3280
  }
2134
- const result = assets.slice(0, MAX_TOTAL_ASSETS);
2135
- while (result.length > 0 && totalBase64Size(result) > MAX_TOTAL_BASE64_SIZE) {
2136
- let largestIdx = 0;
2137
- for (let i = 1; i < result.length; i++) {
2138
- if (result[i].base64_data.length > result[largestIdx].base64_data.length) {
2139
- largestIdx = i;
2140
- }
2141
- }
2142
- result.splice(largestIdx, 1);
2143
- }
2144
- return result;
3281
+ return assets.slice(0, MAX_TOTAL_ASSETS);
2145
3282
  }
2146
3283
 
2147
3284
  // src/api/forvibe-client.ts
@@ -2239,6 +3376,59 @@ var ForvibeClient = class {
2239
3376
  }
2240
3377
  return await response.json();
2241
3378
  }
3379
+ /**
3380
+ * Request signed upload URLs for app assets. CLI uploads the binaries
3381
+ * directly to Supabase Storage, avoiding Vercel's 4.5MB body limit on
3382
+ * the report endpoint.
3383
+ */
3384
+ async getAssetUploadUrls(assets) {
3385
+ if (!this.sessionToken) {
3386
+ throw new Error("Not connected. Please validate OTC code first.");
3387
+ }
3388
+ const response = await fetchWithAuth(
3389
+ `${this.baseUrl}/api/agent/assets/presign`,
3390
+ {
3391
+ method: "POST",
3392
+ headers: {
3393
+ "Content-Type": "application/json",
3394
+ Authorization: `Bearer ${this.sessionToken}`
3395
+ },
3396
+ body: JSON.stringify({ assets })
3397
+ }
3398
+ );
3399
+ if (!response.ok) {
3400
+ const bodyText = await response.text().catch(() => "");
3401
+ let errorMessage;
3402
+ try {
3403
+ errorMessage = JSON.parse(bodyText).error;
3404
+ } catch {
3405
+ }
3406
+ throw new Error(
3407
+ errorMessage || `Failed to get upload URLs (HTTP ${response.status}: ${bodyText.substring(0, 200)})`
3408
+ );
3409
+ }
3410
+ return await response.json();
3411
+ }
3412
+ /**
3413
+ * PUT a binary asset directly to a Supabase Storage signed upload URL.
3414
+ * Do NOT send our Bearer token — the signed URL carries its own auth.
3415
+ */
3416
+ async uploadAssetToSignedUrl(signedUrl, buffer, mimeType) {
3417
+ const response = await fetch(signedUrl, {
3418
+ method: "PUT",
3419
+ headers: {
3420
+ "Content-Type": mimeType,
3421
+ "x-upsert": "true"
3422
+ },
3423
+ body: new Uint8Array(buffer)
3424
+ });
3425
+ if (!response.ok) {
3426
+ const bodyText = await response.text().catch(() => "");
3427
+ throw new Error(
3428
+ `Supabase Storage upload failed (HTTP ${response.status}: ${bodyText.substring(0, 200)})`
3429
+ );
3430
+ }
3431
+ }
2242
3432
  /**
2243
3433
  * Submit codebase review results for combined analysis
2244
3434
  */
@@ -2410,24 +3600,97 @@ function getAvailableProviders(deep = false) {
2410
3600
  }
2411
3601
 
2412
3602
  // src/commands/analyze.ts
2413
- function askQuestion(question) {
2414
- const rl = createInterface({
2415
- input: process.stdin,
2416
- output: process.stdout
2417
- });
2418
- return new Promise((resolve) => {
2419
- rl.question(question, (answer) => {
2420
- rl.close();
2421
- resolve(answer.trim());
2422
- });
2423
- });
3603
+ function mimeForPath(p) {
3604
+ const ext = extname4(p).toLowerCase();
3605
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
3606
+ if (ext === ".webp") return "image/webp";
3607
+ return "image/png";
2424
3608
  }
2425
- async function analyzeCommand(options) {
2426
- const rootDir = options.dir || process.cwd();
2427
- console.log();
2428
- console.log(
2429
- chalk.bold(" Forvibe CLI") + chalk.gray(" \u2014 AI-powered App Store automation")
2430
- );
3609
+ async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
3610
+ const pending = [];
3611
+ for (const asset of assets) {
3612
+ if (!asset.base64_data) continue;
3613
+ pending.push({
3614
+ asset,
3615
+ buffer: Buffer.from(asset.base64_data, "base64")
3616
+ });
3617
+ }
3618
+ if (appIconPath) {
3619
+ try {
3620
+ const stat = statSync6(appIconPath);
3621
+ if (stat.isFile() && stat.size <= 5 * 1024 * 1024) {
3622
+ const buffer = readFileSync5(appIconPath);
3623
+ pending.push({
3624
+ asset: {
3625
+ asset_type: "app_icon",
3626
+ file_name: basename3(appIconPath),
3627
+ mime_type: mimeForPath(appIconPath),
3628
+ width: null,
3629
+ height: null,
3630
+ source_path: relative5(rootDir, appIconPath)
3631
+ },
3632
+ buffer
3633
+ });
3634
+ }
3635
+ } catch {
3636
+ }
3637
+ }
3638
+ if (pending.length === 0) return [];
3639
+ const { uploads } = await client.getAssetUploadUrls(
3640
+ pending.map((p) => ({
3641
+ asset_type: p.asset.asset_type,
3642
+ file_name: p.asset.file_name,
3643
+ mime_type: p.asset.mime_type
3644
+ }))
3645
+ );
3646
+ if (uploads.length !== pending.length) {
3647
+ throw new Error(
3648
+ `Upload slot count mismatch: requested ${pending.length}, got ${uploads.length}`
3649
+ );
3650
+ }
3651
+ const concurrency = 3;
3652
+ for (let i = 0; i < pending.length; i += concurrency) {
3653
+ const chunk = pending.slice(i, i + concurrency);
3654
+ const slots = uploads.slice(i, i + concurrency);
3655
+ await Promise.all(
3656
+ chunk.map(
3657
+ (p, idx) => client.uploadAssetToSignedUrl(
3658
+ slots[idx].signed_url,
3659
+ p.buffer,
3660
+ p.asset.mime_type
3661
+ )
3662
+ )
3663
+ );
3664
+ }
3665
+ return pending.map((p, i) => ({
3666
+ asset_type: p.asset.asset_type,
3667
+ file_name: uploads[i].file_name,
3668
+ mime_type: p.asset.mime_type,
3669
+ width: p.asset.width,
3670
+ height: p.asset.height,
3671
+ source_path: p.asset.source_path,
3672
+ storage_bucket: uploads[i].bucket,
3673
+ storage_path: uploads[i].storage_path
3674
+ }));
3675
+ }
3676
+ function askQuestion(question) {
3677
+ const rl = createInterface({
3678
+ input: process.stdin,
3679
+ output: process.stdout
3680
+ });
3681
+ return new Promise((resolve) => {
3682
+ rl.question(question, (answer) => {
3683
+ rl.close();
3684
+ resolve(answer.trim());
3685
+ });
3686
+ });
3687
+ }
3688
+ async function analyzeCommand(options) {
3689
+ const rootDir = options.dir || process.cwd();
3690
+ console.log();
3691
+ console.log(
3692
+ chalk.bold(" Forvibe CLI") + chalk.gray(" \u2014 AI-powered App Store automation")
3693
+ );
2431
3694
  console.log();
2432
3695
  const availableProviders = getAvailableProviders();
2433
3696
  if (availableProviders.length === 0) {
@@ -2493,7 +3756,7 @@ async function analyzeCommand(options) {
2493
3756
  if (techStack.stack === "unknown") {
2494
3757
  techSpinner.fail(
2495
3758
  chalk.red(
2496
- "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"
2497
3760
  )
2498
3761
  );
2499
3762
  process.exit(1);
@@ -2848,6 +4111,36 @@ async function analyzeCommand(options) {
2848
4111
  console.log(chalk.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"));
2849
4112
  }
2850
4113
  console.log();
4114
+ try {
4115
+ const uploadCount = (report.app_assets?.length ?? 0) + (branding.app_icon_path ? 1 : 0);
4116
+ if (uploadCount > 0) {
4117
+ const uploadSpinner = ora({
4118
+ text: `Uploading ${uploadCount} asset${uploadCount === 1 ? "" : "s"}...`,
4119
+ prefixText: " "
4120
+ }).start();
4121
+ try {
4122
+ const uploaded = await uploadAssetsToStorage(
4123
+ client,
4124
+ rootDir,
4125
+ report.app_assets ?? [],
4126
+ branding.app_icon_path
4127
+ );
4128
+ report.app_assets = uploaded;
4129
+ report.app_icon_base64 = null;
4130
+ uploadSpinner.succeed(chalk.green(`Uploaded ${uploaded.length} asset${uploaded.length === 1 ? "" : "s"}`));
4131
+ } catch (uploadError) {
4132
+ uploadSpinner.fail(
4133
+ chalk.red(
4134
+ `Asset upload failed: ${uploadError instanceof Error ? uploadError.message : "Unknown error"}`
4135
+ )
4136
+ );
4137
+ process.exit(1);
4138
+ }
4139
+ }
4140
+ } catch (e) {
4141
+ console.error(chalk.red(`Asset upload error: ${e instanceof Error ? e.message : String(e)}`));
4142
+ process.exit(1);
4143
+ }
2851
4144
  const sendSpinner = ora({
2852
4145
  text: "Sending report to Forvibe...",
2853
4146
  prefixText: " "
@@ -2870,11 +4163,1575 @@ async function analyzeCommand(options) {
2870
4163
  }
2871
4164
  }
2872
4165
 
2873
- // src/commands/review.ts
2874
- import { createInterface as createInterface2 } from "readline";
2875
- import chalk3 from "chalk";
2876
- import ora2 from "ora";
2877
-
4166
+ // src/commands/review.ts
4167
+ import { createInterface as createInterface2 } from "readline";
4168
+ import chalk3 from "chalk";
4169
+ import ora2 from "ora";
4170
+
4171
+ // src/engine/index.ts
4172
+ import { join as join14 } from "path";
4173
+
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
+ }
4253
+
4254
+ // src/engine/extractors/ios-plist.ts
4255
+ import { join as join9, relative as relative6 } from "path";
4256
+ import plistLib from "plist";
4257
+
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
+ }
4312
+
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
+
2878
5735
  // src/review/feature-detector.ts
2879
5736
  function getFeatureDiscoveryPrompt() {
2880
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.
@@ -2956,62 +5813,23 @@ function parseFeatureAnalysis(response) {
2956
5813
  }
2957
5814
 
2958
5815
  // src/review/rag-selector.ts
2959
- import { readFileSync as readFileSync5 } from "fs";
2960
- import { fileURLToPath } from "url";
2961
- import path from "path";
2962
- var DATA_FILES = [
2963
- "safety.json",
2964
- "performance.json",
2965
- "business.json",
2966
- "design.json",
2967
- "legal.json",
2968
- "common-pitfalls.json"
2969
- ];
2970
- var cachedStories = null;
2971
- function getDataDir() {
2972
- const __filename = fileURLToPath(import.meta.url);
2973
- const __dirname = path.dirname(__filename);
2974
- const candidates = [
2975
- path.join(__dirname, "rag-data"),
2976
- path.join(__dirname, "review", "rag-data"),
2977
- path.join(__dirname, "..", "review", "rag-data"),
2978
- path.join(__dirname, "..", "src", "review", "rag-data")
2979
- ];
2980
- for (const dir of candidates) {
2981
- try {
2982
- readFileSync5(path.join(dir, "safety.json"));
2983
- return dir;
2984
- } catch {
2985
- }
2986
- }
2987
- return candidates[0];
2988
- }
2989
- function loadAllStories() {
2990
- if (cachedStories) return cachedStories;
2991
- const dataDir = getDataDir();
2992
- const stories = [];
2993
- for (const file of DATA_FILES) {
2994
- try {
2995
- const content = readFileSync5(path.join(dataDir, file), "utf-8");
2996
- const parsed = JSON.parse(content);
2997
- stories.push(...parsed);
2998
- } 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);
2999
5830
  }
3000
5831
  }
3001
- cachedStories = stories;
3002
- return stories;
3003
- }
3004
- function searchStories(signals, maxResults = 20) {
3005
- const allStories = loadAllStories();
3006
- if (signals.length === 0) return allStories.slice(0, maxResults);
3007
- const signalKeywords = new Set(
3008
- signals.flatMap((s) => s.keywords.map((k) => k.toLowerCase()))
3009
- );
3010
- const signalFeatures = new Set(
3011
- signals.map((s) => s.feature.toLowerCase())
3012
- );
3013
- const signalCategories = new Set(signals.map((s) => s.category));
3014
- const scored = allStories.map((story) => {
5832
+ const scored = stories.map((story) => {
3015
5833
  let score = 0;
3016
5834
  for (const signal of story.behavioralSignals) {
3017
5835
  const signalLower = signal.toLowerCase();
@@ -3058,6 +5876,21 @@ You do NOT review code quality. You review the APP \u2014 what a user sees, touc
3058
5876
  For every issue, think through this exact scenario:
3059
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]."
3060
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
+
3061
5894
  ## WHAT YOU CHECK (as if using the app)
3062
5895
 
3063
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?
@@ -3108,11 +5941,11 @@ You MUST respond with valid JSON:
3108
5941
  "relatedRejection": "string | null (reference to a similar rejection case if provided)"
3109
5942
  }
3110
5943
  ],
3111
- "overallAssessment": "string (2-3 sentence assessment of the app's review readiness)",
3112
- "estimatedOutcome": "likely-approved | likely-rejected | needs-attention",
3113
- "estimatedApprovalChance": 0-100
5944
+ "overallAssessment": "string (2-3 sentence assessment of the app's review readiness)"
3114
5945
  }
3115
5946
 
5947
+ Do not include an approval estimate; the final outcome is computed deterministically from the merged findings.
5948
+
3116
5949
  ## IMPORTANT RULES
3117
5950
 
3118
5951
  1. Every finding MUST describe user-visible behavior, not code patterns
@@ -3162,14 +5995,8 @@ function validateCategory(c) {
3162
5995
  }
3163
5996
  return "performance";
3164
5997
  }
3165
- function validateOutcome(o) {
3166
- if (["likely-approved", "likely-rejected", "needs-attention"].includes(o)) {
3167
- return o;
3168
- }
3169
- return "needs-attention";
3170
- }
3171
5998
  function convertFindings(raw) {
3172
- return raw.findings.map((f, i) => ({
5999
+ return (raw.findings ?? []).map((f, i) => ({
3173
6000
  id: f.ruleId || `finding-${i + 1}`,
3174
6001
  title: f.title,
3175
6002
  risk_level: validateRiskLevel(f.severity),
@@ -3182,51 +6009,164 @@ function convertFindings(raw) {
3182
6009
  evidence: f.evidence ?? "",
3183
6010
  recommendation: f.fixSuggestion,
3184
6011
  file: f.file ?? void 0,
3185
- 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"
3186
6015
  }));
3187
6016
  }
3188
- function buildSummary(findings, aiResponse) {
3189
- const high = findings.filter((f) => f.risk_level === "high").length;
3190
- const medium = findings.filter((f) => f.risk_level === "medium").length;
3191
- const low = findings.filter((f) => f.risk_level === "low").length;
3192
- const info = findings.filter((f) => f.risk_level === "info").length;
3193
- const outcome = validateOutcome(aiResponse.estimatedOutcome);
3194
- let approvalChance = aiResponse.estimatedApprovalChance ?? 75;
3195
- if (outcome === "likely-rejected") approvalChance = Math.min(approvalChance, 30);
3196
- if (outcome === "likely-approved") approvalChance = Math.max(approvalChance, 70);
3197
- const topRisks = findings.filter((f) => f.risk_level === "high" || f.risk_level === "medium").slice(0, 3).map((f) => f.title);
3198
- return {
3199
- total_findings: findings.length,
3200
- high_risk: high,
3201
- medium_risk: medium,
3202
- low_risk: low,
3203
- info,
3204
- outcome,
3205
- estimated_approval_chance: approvalChance,
3206
- top_risks: topRisks,
3207
- ai_assessment: aiResponse.overallAssessment
3208
- };
6017
+ function normalizeTitle(title) {
6018
+ return title.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
3209
6019
  }
3210
- async function runCodeReview(provider, platform, appName, bundleId, sourceCode, sourceFilesCount, deep) {
3211
- const startTime = Date.now();
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;
3212
6141
  let featureAnalysis = null;
3213
- let relevantStories;
3214
- if (deep) {
6142
+ if (input.deep) {
3215
6143
  featureAnalysis = await detectFeatures(
3216
- provider,
3217
- platform,
3218
- appName,
3219
- bundleId,
3220
- sourceCode
6144
+ input.provider,
6145
+ input.platform,
6146
+ input.appName,
6147
+ input.bundleId,
6148
+ input.sourceContext
3221
6149
  );
3222
6150
  if (featureAnalysis) {
3223
- relevantStories = searchStories(featureAnalysis.features, 20);
3224
- } else {
3225
- relevantStories = loadAllStories().slice(0, 15);
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
+ }
3226
6162
  }
3227
- } else {
3228
- relevantStories = loadAllStories().slice(0, 15);
3229
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);
3230
6170
  const storiesText = formatStoriesForPrompt(relevantStories);
3231
6171
  const featureSection = featureAnalysis ? `## App Analysis (from feature discovery)
3232
6172
 
@@ -3237,60 +6177,136 @@ async function runCodeReview(provider, platform, appName, bundleId, sourceCode,
3237
6177
  ${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confidence: ${f.confidence})`).join("\n")}
3238
6178
 
3239
6179
  **Risk Areas:**
3240
- ${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}` : "";
6180
+ ${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
6181
+
6182
+ ` : "";
3241
6183
  const userPrompt = `## App Information
3242
- - Platform: ${platform}
3243
- - App Name: ${appName}
3244
- - Bundle ID: ${bundleId}
6184
+ - Platform: ${input.platform}
6185
+ - App Name: ${input.appName}
6186
+ - Bundle ID: ${input.bundleId}
6187
+
6188
+ ## App Profile (from static analysis)
6189
+ ${formatProfileDigest(profile)}
6190
+
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:
3245
6193
 
3246
- ${featureSection}
6194
+ ${formatAlreadyDetected(staticFindings)}
3247
6195
 
3248
- ## Real Rejection Cases (from other developers)
6196
+ ${featureSection}## Real Rejection Cases (from other developers)
3249
6197
  These are REAL cases where Apple rejected similar apps. Use them to calibrate:
3250
6198
 
3251
6199
  ${storiesText}
3252
6200
 
3253
- ## Source Code
6201
+ ## Source Code Context
3254
6202
 
3255
- ${sourceCode}
6203
+ ${input.sourceContext}
3256
6204
 
3257
- 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?`;
3258
6206
  const systemPrompt = getReviewerSimulationPrompt();
3259
- const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
6207
+ const response = await input.provider.generateJSON(systemPrompt, userPrompt, 0.2);
3260
6208
  const parsed = parseAIResponse(response);
6209
+ const featuresDetected = featureAnalysis?.features.map((f) => f.feature) ?? [];
3261
6210
  if (!parsed) {
3262
6211
  return {
3263
- features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
3264
- source_files_analyzed: sourceFilesCount,
3265
- platform,
3266
- bundle_id: bundleId || null,
3267
- findings: [],
3268
- summary: {
3269
- total_findings: 0,
3270
- high_risk: 0,
3271
- medium_risk: 0,
3272
- low_risk: 0,
3273
- info: 0,
3274
- outcome: "needs-attention",
3275
- estimated_approval_chance: 50,
3276
- top_risks: [],
3277
- ai_assessment: "AI analysis could not produce structured results."
3278
- },
3279
- rag_stories_used: relevantStories.length,
3280
- 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
3281
6220
  };
3282
6221
  }
3283
- const findings = convertFindings(parsed);
3284
- const summary = buildSummary(findings, parsed);
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
+ });
3285
6293
  return {
3286
- features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
3287
- source_files_analyzed: sourceFilesCount,
3288
- platform,
3289
- bundle_id: bundleId || null,
6294
+ features_detected: input.featuresDetected,
6295
+ source_files_analyzed: input.sourceFilesAnalyzed,
6296
+ platform: input.platform,
6297
+ bundle_id: input.bundleId,
3290
6298
  findings,
3291
6299
  summary,
3292
- rag_stories_used: relevantStories.length,
3293
- scan_duration_ms: Date.now() - startTime
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
3294
6310
  };
3295
6311
  }
3296
6312
 
@@ -3314,6 +6330,7 @@ var RISK_COLORS = {
3314
6330
  low: chalk2.blue,
3315
6331
  info: chalk2.gray
3316
6332
  };
6333
+ var STATIC_ONLY_BANNER = "AI behavioral review skipped. Static rule results only.";
3317
6334
  function outcomeDisplay(outcome) {
3318
6335
  switch (outcome) {
3319
6336
  case "likely-approved":
@@ -3336,6 +6353,30 @@ function approvalBar(chance) {
3336
6353
  else color = chalk2.red;
3337
6354
  return color("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty)) + ` ${chance}%`;
3338
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
+ }
3339
6380
  function formatTerminal(report) {
3340
6381
  const lines = [];
3341
6382
  lines.push("");
@@ -3359,7 +6400,9 @@ function formatTerminal(report) {
3359
6400
  report.summary.low_risk > 0 && chalk2.blue(`${report.summary.low_risk} low`),
3360
6401
  report.summary.info > 0 && chalk2.gray(`${report.summary.info} info`)
3361
6402
  ].filter(Boolean);
3362
- lines.push(` Findings: ${chalk2.bold(String(report.summary.total_findings))} total \u2014 ${counts.join(" \xB7 ")}`);
6403
+ lines.push(
6404
+ ` Findings: ${chalk2.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
6405
+ );
3363
6406
  lines.push("");
3364
6407
  if (report.features_detected.length > 0) {
3365
6408
  lines.push(` Features: ${chalk2.gray(report.features_detected.join(", "))}`);
@@ -3371,6 +6414,29 @@ function formatTerminal(report) {
3371
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"));
3372
6415
  lines.push("");
3373
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
+ }
3374
6440
  const findingsByRisk = {
3375
6441
  high: [],
3376
6442
  medium: [],
@@ -3388,10 +6454,10 @@ function formatTerminal(report) {
3388
6454
  const label = RISK_LABELS[level];
3389
6455
  const color = RISK_COLORS[level];
3390
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"));
3391
- lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
6457
+ lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} - ${finding.guideline_name}`);
3392
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"));
3393
6459
  lines.push("");
3394
- lines.push(` ${chalk2.bold(finding.title)}`);
6460
+ lines.push(` ${detectionBadge(finding)}${chalk2.bold(finding.title)}`);
3395
6461
  lines.push(` ${finding.description}`);
3396
6462
  lines.push("");
3397
6463
  lines.push(` ${chalk2.cyan("User Impact:")} ${finding.user_impact}`);
@@ -3410,7 +6476,14 @@ function formatTerminal(report) {
3410
6476
  lines.push("");
3411
6477
  }
3412
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"));
3413
- lines.push(chalk2.gray(" Powered by Forvibe CLI \u2014 forvibe.app"));
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)"));
3414
6487
  lines.push("");
3415
6488
  return lines.join("\n");
3416
6489
  }
@@ -3429,6 +6502,10 @@ function formatMarkdown(report) {
3429
6502
  lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
3430
6503
  lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
3431
6504
  lines.push("");
6505
+ if (report.ai_review_ran === false) {
6506
+ lines.push(`> ${STATIC_ONLY_BANNER}`);
6507
+ lines.push("");
6508
+ }
3432
6509
  lines.push("## Result");
3433
6510
  lines.push("");
3434
6511
  lines.push(`**Outcome:** ${report.summary.outcome.replace(/-/g, " ").toUpperCase()}`);
@@ -3446,6 +6523,26 @@ function formatMarkdown(report) {
3446
6523
  lines.push(`> ${report.summary.ai_assessment}`);
3447
6524
  lines.push("");
3448
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
+ }
3449
6546
  if (report.features_detected.length > 0) {
3450
6547
  lines.push("## Detected Features");
3451
6548
  lines.push("");
@@ -3459,13 +6556,15 @@ function formatMarkdown(report) {
3459
6556
  if (report.findings.length === 0) {
3460
6557
  lines.push("No significant issues found. Your app looks ready for review!");
3461
6558
  lines.push("");
6559
+ appendMarkdownFooter(lines, report);
3462
6560
  return lines.join("\n");
3463
6561
  }
3464
6562
  for (const finding of report.findings) {
3465
6563
  const riskEmoji = RISK_ICONS[finding.risk_level];
3466
- lines.push(`### ${riskEmoji} ${finding.title}`);
6564
+ const badge = finding.detection === "static" ? "[static] " : finding.detection === "ai" ? "[AI] " : "";
6565
+ lines.push(`### ${riskEmoji} ${badge}${finding.title}`);
3467
6566
  lines.push("");
3468
- lines.push(`**Guideline:** ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
6567
+ lines.push(`**Guideline:** ${finding.guideline_number} - ${finding.guideline_name}`);
3469
6568
  lines.push(`**Risk Level:** ${finding.risk_level}`);
3470
6569
  lines.push(`**Category:** ${finding.category}`);
3471
6570
  lines.push("");
@@ -3482,12 +6581,36 @@ function formatMarkdown(report) {
3482
6581
  lines.push("---");
3483
6582
  lines.push("");
3484
6583
  }
3485
- lines.push("*Powered by Forvibe CLI \u2014 forvibe.app*");
3486
- lines.push("");
6584
+ appendMarkdownFooter(lines, report);
3487
6585
  return lines.join("\n");
3488
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
+ }
3489
6611
 
3490
6612
  // src/commands/review.ts
6613
+ var DEFAULT_RULEPACK_BASE_URL = "https://forvibe.app";
3491
6614
  function askQuestion2(question) {
3492
6615
  const rl = createInterface2({
3493
6616
  input: process.stdin,
@@ -3503,55 +6626,14 @@ function askQuestion2(question) {
3503
6626
  async function reviewCommand(options) {
3504
6627
  const rootDir = options.dir || process.cwd();
3505
6628
  const deep = options.deep ?? false;
6629
+ const staticOnly = options.staticOnly ?? false;
3506
6630
  const format = options.format || "terminal";
6631
+ const startTime = Date.now();
3507
6632
  console.log();
3508
6633
  console.log(
3509
- chalk3.bold(" Forvibe CLI") + chalk3.gray(" \u2014 App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "")
6634
+ chalk3.bold(" Forvibe CLI") + chalk3.gray(" - App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "") + (staticOnly ? chalk3.cyan(" [STATIC ONLY]") : "")
3510
6635
  );
3511
6636
  console.log();
3512
- const availableProviders = getAvailableProviders(deep);
3513
- if (availableProviders.length === 0) {
3514
- console.log(chalk3.red(" \u2717 No AI API key found. Set one of the following:\n"));
3515
- console.log(
3516
- chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
3517
- );
3518
- console.log(
3519
- chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
3520
- );
3521
- console.log(
3522
- chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
3523
- );
3524
- console.log();
3525
- console.log(
3526
- chalk3.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n")
3527
- );
3528
- process.exit(1);
3529
- }
3530
- let provider;
3531
- if (availableProviders.length === 1) {
3532
- provider = availableProviders[0].create();
3533
- } else {
3534
- console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
3535
- availableProviders.forEach((p, i) => {
3536
- const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
3537
- console.log(` ${label}`);
3538
- });
3539
- console.log();
3540
- const answer = await askQuestion2(
3541
- chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
3542
- );
3543
- const index = parseInt(answer, 10) - 1;
3544
- if (isNaN(index) || index < 0 || index >= availableProviders.length) {
3545
- const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
3546
- provider = recommended.create();
3547
- console.log(chalk3.gray(` Using ${recommended.name} (default)
3548
- `));
3549
- } else {
3550
- provider = availableProviders[index].create();
3551
- }
3552
- }
3553
- console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
3554
- console.log();
3555
6637
  const techSpinner = ora2({
3556
6638
  text: "Detecting tech stack...",
3557
6639
  prefixText: " "
@@ -3560,7 +6642,7 @@ async function reviewCommand(options) {
3560
6642
  if (techStack.stack === "unknown") {
3561
6643
  techSpinner.fail(
3562
6644
  chalk3.red(
3563
- "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"
3564
6646
  )
3565
6647
  );
3566
6648
  process.exit(1);
@@ -3578,59 +6660,199 @@ async function reviewCommand(options) {
3578
6660
  configSpinner.succeed(
3579
6661
  `Config: ${chalk3.white(appName)} ${bundleId ? chalk3.gray(`(${bundleId})`) : ""}`
3580
6662
  );
3581
- const sdkSpinner = ora2({
3582
- text: "Scanning dependencies...",
6663
+ const rulepackSpinner = ora2({
6664
+ text: "Loading review rulepack...",
3583
6665
  prefixText: " "
3584
6666
  }).start();
3585
- const sdkScan = scanSDKs(rootDir, techStack.stack);
3586
- sdkSpinner.succeed(
3587
- `Dependencies: ${chalk3.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
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})`)}`
3588
6680
  );
3589
6681
  const sourceSpinner = ora2({
3590
6682
  text: "Reading source code...",
3591
6683
  prefixText: " "
3592
6684
  }).start();
3593
6685
  const maxChars = deep ? 4e5 : 15e4;
3594
- const sourceCode = readSourceCode(rootDir, techStack.stack, maxChars);
6686
+ const sourceFiles = readSourceFiles(rootDir, techStack.stack, maxChars);
6687
+ const sourceCode = joinSourceFiles(sourceFiles);
3595
6688
  const projectTree = generateProjectTree(rootDir);
3596
6689
  const sourceLines = sourceCode.split("\n").length;
3597
6690
  sourceSpinner.succeed(
3598
- `Source code: ${chalk3.bold(String(sourceLines))} lines analyzed`
6691
+ `Source code: ${chalk3.bold(String(sourceLines))} lines in ${chalk3.bold(String(sourceFiles.length))} files`
3599
6692
  );
3600
- console.log();
3601
- const reviewSpinner = ora2({
3602
- 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...",
3603
6695
  prefixText: " "
3604
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
+ }
3605
6763
  let report;
3606
- try {
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 {
3607
6785
  const enrichedSource = buildEnrichedContext(
3608
6786
  sourceCode,
3609
6787
  projectTree,
3610
- sdkScan,
3611
- config
3612
- );
3613
- report = await runCodeReview(
3614
- provider,
3615
- techStack.label,
3616
- appName,
3617
- bundleId,
3618
- enrichedSource,
3619
- sourceLines,
3620
- deep
3621
- );
3622
- reviewSpinner.succeed(
3623
- chalk3.green(
3624
- `Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
3625
- )
3626
- );
3627
- } catch (error) {
3628
- reviewSpinner.fail(
3629
- chalk3.red(
3630
- `Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
3631
- )
6788
+ staticResult.profile.sdks
3632
6789
  );
3633
- process.exit(1);
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
+ }
3634
6856
  }
3635
6857
  switch (format) {
3636
6858
  case "json":
@@ -3695,14 +6917,16 @@ async function reviewCommand(options) {
3695
6917
  }
3696
6918
  }
3697
6919
  }
3698
- function buildEnrichedContext(sourceCode, projectTree, sdkScan, config) {
6920
+ function buildEnrichedContext(sourceCode, projectTree, sdks) {
3699
6921
  const sections = [];
3700
6922
  sections.push(`## Project Structure
3701
6923
  \`\`\`
3702
6924
  ${projectTree}
3703
6925
  \`\`\``);
3704
- if (sdkScan.detected_sdks.length > 0) {
3705
- const sdkList = sdkScan.detected_sdks.map((sdk) => `- ${sdk.name} (${sdk.category})${sdk.collects.length > 0 ? ` \u2014 collects: ${sdk.collects.join(", ")}` : ""}`).join("\n");
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");
3706
6930
  sections.push(`## Detected SDKs
3707
6931
  ${sdkList}`);
3708
6932
  }
@@ -3714,7 +6938,7 @@ ${sourceCode}`);
3714
6938
 
3715
6939
  // src/index.ts
3716
6940
  var program = new Command();
3717
- program.name("forvibe").description("Forvibe CLI \u2014 AI-powered App Store automation").version("0.1.0");
6941
+ program.name("forvibe").description("Forvibe CLI - AI-powered App Store automation").version(getCliVersion());
3718
6942
  program.command("analyze", { isDefault: true }).description(
3719
6943
  "Analyze your project and send the report to Forvibe for automated App Store setup"
3720
6944
  ).option("-d, --dir <path>", "Project directory to analyze", process.cwd()).option(
@@ -3724,9 +6948,13 @@ program.command("analyze", { isDefault: true }).description(
3724
6948
  ).action(analyzeCommand);
3725
6949
  program.command("review").description(
3726
6950
  "Simulate Apple's App Store review process on your project"
3727
- ).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(
3728
6952
  "--api-url <url>",
3729
6953
  "Forvibe API URL (for development)",
3730
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
3731
6959
  ).action(reviewCommand);
3732
6960
  program.parse();