@forvibe/cli 1.1.1 → 2.0.0

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