@forvibe/cli 2.0.0 → 2.0.1
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 +366 -2669
- package/dist/review/rag-data/business.json +282 -0
- package/dist/review/rag-data/common-pitfalls.json +212 -0
- package/dist/review/rag-data/design.json +212 -0
- package/dist/review/rag-data/legal.json +212 -0
- package/dist/review/rag-data/performance.json +282 -0
- package/dist/review/rag-data/safety.json +212 -0
- package/package.json +3 -6
- package/dist/engine/data/bundle.appstore.json +0 -10098
package/dist/index.js
CHANGED
|
@@ -6,13 +6,13 @@ import { Command } from "commander";
|
|
|
6
6
|
// src/commands/analyze.ts
|
|
7
7
|
import { createInterface } from "readline";
|
|
8
8
|
import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
|
|
9
|
-
import { basename as basename3, extname as extname4, relative as
|
|
9
|
+
import { basename as basename3, extname as extname4, relative as relative4 } 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
|
|
15
|
+
import { join as join2 } from "path";
|
|
16
16
|
|
|
17
17
|
// src/utils/file-scanner.ts
|
|
18
18
|
import { readFileSync, readdirSync, statSync, existsSync } from "fs";
|
|
@@ -129,7 +129,7 @@ function findFiles(rootDir, extensions, maxDepth = 5) {
|
|
|
129
129
|
(f) => join(rootDir, f)
|
|
130
130
|
);
|
|
131
131
|
}
|
|
132
|
-
function
|
|
132
|
+
function readFileSafe2(filePath) {
|
|
133
133
|
try {
|
|
134
134
|
if (!existsSync(filePath)) return null;
|
|
135
135
|
const stat = statSync(filePath);
|
|
@@ -139,22 +139,8 @@ function readFileSafe(filePath) {
|
|
|
139
139
|
return null;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
|
-
function fileExists(filePath) {
|
|
143
|
-
return existsSync(filePath);
|
|
144
|
-
}
|
|
145
142
|
|
|
146
143
|
// 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
|
-
}
|
|
158
144
|
var DETECTION_RULES = [
|
|
159
145
|
{
|
|
160
146
|
stack: "flutter",
|
|
@@ -176,7 +162,7 @@ var DETECTION_RULES = [
|
|
|
176
162
|
detect: (dir) => {
|
|
177
163
|
const files = [];
|
|
178
164
|
const pkgPath = join2(dir, "package.json");
|
|
179
|
-
const pkgContent = existsSync2(pkgPath) ?
|
|
165
|
+
const pkgContent = existsSync2(pkgPath) ? readFileSafe2(pkgPath) : null;
|
|
180
166
|
let hasExpoDep = false;
|
|
181
167
|
if (pkgContent) {
|
|
182
168
|
try {
|
|
@@ -191,7 +177,7 @@ var DETECTION_RULES = [
|
|
|
191
177
|
let hasExpoBlock = false;
|
|
192
178
|
const appJsonPath = join2(dir, "app.json");
|
|
193
179
|
if (existsSync2(appJsonPath)) {
|
|
194
|
-
const appJson =
|
|
180
|
+
const appJson = readFileSafe2(appJsonPath);
|
|
195
181
|
if (appJson && /"expo"\s*:\s*\{/.test(appJson)) {
|
|
196
182
|
hasExpoBlock = true;
|
|
197
183
|
}
|
|
@@ -216,7 +202,7 @@ var DETECTION_RULES = [
|
|
|
216
202
|
const files = [];
|
|
217
203
|
const pkgPath = join2(dir, "package.json");
|
|
218
204
|
if (existsSync2(pkgPath)) {
|
|
219
|
-
const content =
|
|
205
|
+
const content = readFileSafe2(pkgPath);
|
|
220
206
|
if (content && content.includes('"react-native"')) {
|
|
221
207
|
files.push("package.json");
|
|
222
208
|
if (existsSync2(join2(dir, "app.json"))) files.push("app.json");
|
|
@@ -251,7 +237,7 @@ var DETECTION_RULES = [
|
|
|
251
237
|
},
|
|
252
238
|
{
|
|
253
239
|
stack: "swift",
|
|
254
|
-
label: "
|
|
240
|
+
label: "Swift / SwiftUI",
|
|
255
241
|
platforms: ["ios"],
|
|
256
242
|
detect: (dir) => {
|
|
257
243
|
const files = [];
|
|
@@ -268,7 +254,7 @@ var DETECTION_RULES = [
|
|
|
268
254
|
try {
|
|
269
255
|
const entries = readdirSync2(dir);
|
|
270
256
|
for (const entry of entries) {
|
|
271
|
-
if (entry.endsWith(".swift")
|
|
257
|
+
if (entry.endsWith(".swift")) {
|
|
272
258
|
files.push(entry);
|
|
273
259
|
break;
|
|
274
260
|
}
|
|
@@ -278,37 +264,6 @@ var DETECTION_RULES = [
|
|
|
278
264
|
return files;
|
|
279
265
|
}
|
|
280
266
|
},
|
|
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
|
-
},
|
|
312
267
|
{
|
|
313
268
|
stack: "kotlin",
|
|
314
269
|
label: "Android (Kotlin)",
|
|
@@ -339,7 +294,7 @@ var DETECTION_RULES = [
|
|
|
339
294
|
const entries = readdirSync2(dir);
|
|
340
295
|
for (const entry of entries) {
|
|
341
296
|
if (entry.endsWith(".csproj")) {
|
|
342
|
-
const content =
|
|
297
|
+
const content = readFileSafe2(join2(dir, entry));
|
|
343
298
|
if (!content) continue;
|
|
344
299
|
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
300
|
if (isMaui) {
|
|
@@ -396,10 +351,6 @@ function parseConfig(rootDir, techStack) {
|
|
|
396
351
|
return parseCapacitorConfig(rootDir);
|
|
397
352
|
case "dotnet-maui":
|
|
398
353
|
return parseMauiConfig(rootDir);
|
|
399
|
-
case "unity":
|
|
400
|
-
return parseUnityConfig(rootDir);
|
|
401
|
-
case "kmp":
|
|
402
|
-
return parseKmpConfig(rootDir);
|
|
403
354
|
default:
|
|
404
355
|
return emptyConfig();
|
|
405
356
|
}
|
|
@@ -416,7 +367,7 @@ function emptyConfig() {
|
|
|
416
367
|
}
|
|
417
368
|
function parseFlutterConfig(rootDir) {
|
|
418
369
|
const config = emptyConfig();
|
|
419
|
-
const pubspecContent =
|
|
370
|
+
const pubspecContent = readFileSafe2(join3(rootDir, "pubspec.yaml"));
|
|
420
371
|
if (pubspecContent) {
|
|
421
372
|
try {
|
|
422
373
|
const pubspec = YAML.parse(pubspecContent);
|
|
@@ -441,7 +392,7 @@ function parseFlutterConfig(rootDir) {
|
|
|
441
392
|
if (!config.bundle_id || config.bundle_id.includes("$(")) {
|
|
442
393
|
const pbxprojPath = findFile(join3(rootDir, "ios"), "project.pbxproj", 4);
|
|
443
394
|
if (pbxprojPath) {
|
|
444
|
-
const content =
|
|
395
|
+
const content = readFileSafe2(pbxprojPath);
|
|
445
396
|
if (content) {
|
|
446
397
|
const bundleMatch = content.match(
|
|
447
398
|
/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)"?/
|
|
@@ -464,7 +415,7 @@ function parseFlutterConfig(rootDir) {
|
|
|
464
415
|
}
|
|
465
416
|
function parseExpoConfig(rootDir) {
|
|
466
417
|
const config = emptyConfig();
|
|
467
|
-
const appJsonContent =
|
|
418
|
+
const appJsonContent = readFileSafe2(join3(rootDir, "app.json"));
|
|
468
419
|
if (appJsonContent) {
|
|
469
420
|
try {
|
|
470
421
|
const appJson = JSON.parse(appJsonContent);
|
|
@@ -479,7 +430,7 @@ function parseExpoConfig(rootDir) {
|
|
|
479
430
|
}
|
|
480
431
|
if (!config.app_name || !config.bundle_id || !config.version) {
|
|
481
432
|
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
482
|
-
const content =
|
|
433
|
+
const content = readFileSafe2(join3(rootDir, name));
|
|
483
434
|
if (!content) continue;
|
|
484
435
|
if (!config.app_name) {
|
|
485
436
|
const m = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
@@ -499,7 +450,7 @@ function parseExpoConfig(rootDir) {
|
|
|
499
450
|
}
|
|
500
451
|
}
|
|
501
452
|
}
|
|
502
|
-
const pkgContent =
|
|
453
|
+
const pkgContent = readFileSafe2(join3(rootDir, "package.json"));
|
|
503
454
|
if (pkgContent) {
|
|
504
455
|
try {
|
|
505
456
|
const pkg = JSON.parse(pkgContent);
|
|
@@ -540,7 +491,7 @@ function parseExpoConfig(rootDir) {
|
|
|
540
491
|
}
|
|
541
492
|
function parseReactNativeConfig(rootDir) {
|
|
542
493
|
const config = emptyConfig();
|
|
543
|
-
const appJsonContent =
|
|
494
|
+
const appJsonContent = readFileSafe2(join3(rootDir, "app.json"));
|
|
544
495
|
if (appJsonContent) {
|
|
545
496
|
try {
|
|
546
497
|
const appJson = JSON.parse(appJsonContent);
|
|
@@ -550,7 +501,7 @@ function parseReactNativeConfig(rootDir) {
|
|
|
550
501
|
}
|
|
551
502
|
if (!config.app_name) {
|
|
552
503
|
for (const name of ["app.config.ts", "app.config.js"]) {
|
|
553
|
-
const content =
|
|
504
|
+
const content = readFileSafe2(join3(rootDir, name));
|
|
554
505
|
if (!content) continue;
|
|
555
506
|
const m = content.match(/name\s*:\s*['"]([^'"]+)['"]/);
|
|
556
507
|
if (m) {
|
|
@@ -559,7 +510,7 @@ function parseReactNativeConfig(rootDir) {
|
|
|
559
510
|
}
|
|
560
511
|
}
|
|
561
512
|
}
|
|
562
|
-
const pkgContent =
|
|
513
|
+
const pkgContent = readFileSafe2(join3(rootDir, "package.json"));
|
|
563
514
|
if (pkgContent) {
|
|
564
515
|
try {
|
|
565
516
|
const pkg = JSON.parse(pkgContent);
|
|
@@ -586,7 +537,7 @@ function parseReactNativeConfig(rootDir) {
|
|
|
586
537
|
if (!config.bundle_id || config.bundle_id.includes("$(")) {
|
|
587
538
|
const pbxprojPath = findFile(join3(rootDir, "ios"), "project.pbxproj", 4);
|
|
588
539
|
if (pbxprojPath) {
|
|
589
|
-
const content =
|
|
540
|
+
const content = readFileSafe2(pbxprojPath);
|
|
590
541
|
if (content) {
|
|
591
542
|
const bundleMatch = content.match(
|
|
592
543
|
/PRODUCT_BUNDLE_IDENTIFIER\s*=\s*"?([^";]+)"?/
|
|
@@ -698,7 +649,7 @@ function parseSwiftConfig(rootDir) {
|
|
|
698
649
|
const config = emptyConfig();
|
|
699
650
|
const pbxprojPath = findFile(rootDir, "project.pbxproj", 5);
|
|
700
651
|
if (pbxprojPath) {
|
|
701
|
-
const content =
|
|
652
|
+
const content = readFileSafe2(pbxprojPath);
|
|
702
653
|
if (content) {
|
|
703
654
|
const pbx = parsePbxproj(content);
|
|
704
655
|
config.bundle_id = pbx.bundleId;
|
|
@@ -780,7 +731,7 @@ function parseCapacitorConfig(rootDir) {
|
|
|
780
731
|
"capacitor.config.ts",
|
|
781
732
|
"capacitor.config.js"
|
|
782
733
|
]) {
|
|
783
|
-
const content =
|
|
734
|
+
const content = readFileSafe2(join3(rootDir, name));
|
|
784
735
|
if (content) {
|
|
785
736
|
if (name.endsWith(".json")) {
|
|
786
737
|
try {
|
|
@@ -798,7 +749,7 @@ function parseCapacitorConfig(rootDir) {
|
|
|
798
749
|
break;
|
|
799
750
|
}
|
|
800
751
|
}
|
|
801
|
-
const pkgContent =
|
|
752
|
+
const pkgContent = readFileSafe2(join3(rootDir, "package.json"));
|
|
802
753
|
if (pkgContent) {
|
|
803
754
|
try {
|
|
804
755
|
const pkg = JSON.parse(pkgContent);
|
|
@@ -815,7 +766,7 @@ function parseMauiConfig(rootDir) {
|
|
|
815
766
|
const entries = readdirSync3(rootDir);
|
|
816
767
|
for (const entry of entries) {
|
|
817
768
|
if (entry.endsWith(".csproj")) {
|
|
818
|
-
const content =
|
|
769
|
+
const content = readFileSafe2(join3(rootDir, entry));
|
|
819
770
|
if (content) {
|
|
820
771
|
const appIdMatch = content.match(
|
|
821
772
|
/<ApplicationId>(.*?)<\/ApplicationId>/
|
|
@@ -837,60 +788,8 @@ function parseMauiConfig(rootDir) {
|
|
|
837
788
|
}
|
|
838
789
|
return config;
|
|
839
790
|
}
|
|
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
|
-
}
|
|
892
791
|
function parsePlist(filePath) {
|
|
893
|
-
const content =
|
|
792
|
+
const content = readFileSafe2(filePath);
|
|
894
793
|
if (!content) return null;
|
|
895
794
|
try {
|
|
896
795
|
return plist.parse(content);
|
|
@@ -901,7 +800,7 @@ function parsePlist(filePath) {
|
|
|
901
800
|
function lookupAndroidString(searchDir, stringName) {
|
|
902
801
|
const stringsPath = findFile(searchDir, "strings.xml", 8);
|
|
903
802
|
if (!stringsPath) return null;
|
|
904
|
-
const content =
|
|
803
|
+
const content = readFileSafe2(stringsPath);
|
|
905
804
|
if (!content) return null;
|
|
906
805
|
const escaped = stringName.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
907
806
|
const pattern = new RegExp(
|
|
@@ -913,7 +812,7 @@ function lookupAndroidString(searchDir, stringName) {
|
|
|
913
812
|
function readAndroidAppName(searchDir) {
|
|
914
813
|
const manifestPath = findFile(searchDir, "AndroidManifest.xml", 6);
|
|
915
814
|
if (manifestPath) {
|
|
916
|
-
const content =
|
|
815
|
+
const content = readFileSafe2(manifestPath);
|
|
917
816
|
if (content) {
|
|
918
817
|
const labelMatch = content.match(/android:label\s*=\s*"([^"]+)"/);
|
|
919
818
|
if (labelMatch) {
|
|
@@ -931,7 +830,7 @@ function readAndroidAppName(searchDir) {
|
|
|
931
830
|
return lookupAndroidString(searchDir, "app_name");
|
|
932
831
|
}
|
|
933
832
|
function parseGradle(filePath) {
|
|
934
|
-
const content =
|
|
833
|
+
const content = readFileSafe2(filePath);
|
|
935
834
|
if (!content) return { applicationId: null, versionName: null, minSdk: null };
|
|
936
835
|
const appIdMatch = content.match(
|
|
937
836
|
/applicationId\s*[=:]\s*['"]([^'"]+)['"]/
|
|
@@ -1271,7 +1170,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1271
1170
|
const deps = [];
|
|
1272
1171
|
switch (techStack) {
|
|
1273
1172
|
case "flutter": {
|
|
1274
|
-
const pubspecContent =
|
|
1173
|
+
const pubspecContent = readFileSafe2(join4(rootDir, "pubspec.yaml"));
|
|
1275
1174
|
if (pubspecContent) {
|
|
1276
1175
|
try {
|
|
1277
1176
|
const pubspec = YAML2.parse(pubspecContent);
|
|
@@ -1289,7 +1188,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1289
1188
|
case "expo":
|
|
1290
1189
|
case "react-native":
|
|
1291
1190
|
case "capacitor": {
|
|
1292
|
-
const pkgContent =
|
|
1191
|
+
const pkgContent = readFileSafe2(join4(rootDir, "package.json"));
|
|
1293
1192
|
if (pkgContent) {
|
|
1294
1193
|
try {
|
|
1295
1194
|
const pkg = JSON.parse(pkgContent);
|
|
@@ -1313,7 +1212,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1313
1212
|
if (!csprojFiles.includes(f)) csprojFiles.push(f);
|
|
1314
1213
|
}
|
|
1315
1214
|
for (const path2 of csprojFiles) {
|
|
1316
|
-
const content =
|
|
1215
|
+
const content = readFileSafe2(path2);
|
|
1317
1216
|
if (!content) continue;
|
|
1318
1217
|
const pkgMatches = content.matchAll(
|
|
1319
1218
|
/<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/g
|
|
@@ -1325,14 +1224,14 @@ function getDependencies(rootDir, techStack) {
|
|
|
1325
1224
|
break;
|
|
1326
1225
|
}
|
|
1327
1226
|
case "swift": {
|
|
1328
|
-
const podfileContent =
|
|
1227
|
+
const podfileContent = readFileSafe2(join4(rootDir, "Podfile"));
|
|
1329
1228
|
if (podfileContent) {
|
|
1330
1229
|
const podMatches = podfileContent.matchAll(/pod\s+['"]([^'"]+)['"]/g);
|
|
1331
1230
|
for (const match of podMatches) {
|
|
1332
1231
|
deps.push(match[1]);
|
|
1333
1232
|
}
|
|
1334
1233
|
}
|
|
1335
|
-
const packageSwift =
|
|
1234
|
+
const packageSwift = readFileSafe2(join4(rootDir, "Package.swift"));
|
|
1336
1235
|
if (packageSwift) {
|
|
1337
1236
|
const urlMatches = packageSwift.matchAll(
|
|
1338
1237
|
/\.package\([^)]*url:\s*"[^"]*\/([^/"]+?)(?:\.git)?"/g
|
|
@@ -1343,7 +1242,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1343
1242
|
}
|
|
1344
1243
|
const pbxprojPath = findFile(rootDir, "project.pbxproj", 5);
|
|
1345
1244
|
if (pbxprojPath) {
|
|
1346
|
-
const pbxContent =
|
|
1245
|
+
const pbxContent = readFileSafe2(pbxprojPath);
|
|
1347
1246
|
if (pbxContent) {
|
|
1348
1247
|
const spmMatches = pbxContent.matchAll(
|
|
1349
1248
|
/repositoryURL\s*=\s*"[^"]*\/([^/"]+?)(?:\.git)?"/g
|
|
@@ -1358,7 +1257,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1358
1257
|
const swiftFiles = findFiles(rootDir, [".swift"], 6);
|
|
1359
1258
|
const nativeImports = /* @__PURE__ */ new Set();
|
|
1360
1259
|
for (const file of swiftFiles.slice(0, 100)) {
|
|
1361
|
-
const content =
|
|
1260
|
+
const content = readFileSafe2(file);
|
|
1362
1261
|
if (!content) continue;
|
|
1363
1262
|
const importMatches = content.matchAll(/^import\s+(\w+)/gm);
|
|
1364
1263
|
for (const match of importMatches) {
|
|
@@ -1377,7 +1276,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1377
1276
|
join4(rootDir, "gradle/libs.versions.toml"),
|
|
1378
1277
|
join4(rootDir, "libs.versions.toml")
|
|
1379
1278
|
]) {
|
|
1380
|
-
const content =
|
|
1279
|
+
const content = readFileSafe2(p);
|
|
1381
1280
|
if (content) parseVersionCatalog(content, catalog);
|
|
1382
1281
|
}
|
|
1383
1282
|
const gradleFiles = /* @__PURE__ */ new Set([
|
|
@@ -1401,7 +1300,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1401
1300
|
const coordRegex = /['"]([a-zA-Z][\w.-]*\.[a-zA-Z][\w.-]*:[a-zA-Z][\w.-]*(?::[^'"\s]+)?)['"]/g;
|
|
1402
1301
|
const libsRefRegex = /\blibs\.([a-zA-Z][\w.]*)/g;
|
|
1403
1302
|
for (const path2 of gradleFiles) {
|
|
1404
|
-
const content =
|
|
1303
|
+
const content = readFileSafe2(path2);
|
|
1405
1304
|
if (!content) continue;
|
|
1406
1305
|
for (const m of content.matchAll(coordRegex)) {
|
|
1407
1306
|
addCoord(m[1]);
|
|
@@ -1434,7 +1333,7 @@ function getDependencies(rootDir, techStack) {
|
|
|
1434
1333
|
if (seen.has(file)) continue;
|
|
1435
1334
|
seen.add(file);
|
|
1436
1335
|
if (seen.size > 200) break;
|
|
1437
|
-
const content =
|
|
1336
|
+
const content = readFileSafe2(file);
|
|
1438
1337
|
if (!content) continue;
|
|
1439
1338
|
for (const prefix of androidFrameworkPrefixes) {
|
|
1440
1339
|
if (content.includes(`import ${prefix}`) && !androidNativeImports.has(prefix)) {
|
|
@@ -1448,259 +1347,6 @@ function getDependencies(rootDir, techStack) {
|
|
|
1448
1347
|
}
|
|
1449
1348
|
return deps;
|
|
1450
1349
|
}
|
|
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
|
-
}
|
|
1704
1350
|
|
|
1705
1351
|
// src/analyzers/branding.ts
|
|
1706
1352
|
import { readFileSync as readFileSync2, existsSync as existsSync3, readdirSync as readdirSync5, statSync as statSync2 } from "fs";
|
|
@@ -1753,7 +1399,7 @@ function extractExpoColors(rootDir) {
|
|
|
1753
1399
|
if (/^#[0-9a-fA-F]{8}$/.test(v)) return `#${v.slice(3).toLowerCase()}`;
|
|
1754
1400
|
return null;
|
|
1755
1401
|
};
|
|
1756
|
-
const appJsonContent =
|
|
1402
|
+
const appJsonContent = readFileSafe2(join5(rootDir, "app.json"));
|
|
1757
1403
|
if (appJsonContent) {
|
|
1758
1404
|
try {
|
|
1759
1405
|
const data = JSON.parse(appJsonContent);
|
|
@@ -1794,7 +1440,7 @@ function extractMauiColors(rootDir) {
|
|
|
1794
1440
|
if (/Colors\.xaml$/i.test(p) && !xamlPaths.includes(p)) xamlPaths.push(p);
|
|
1795
1441
|
}
|
|
1796
1442
|
for (const path2 of xamlPaths) {
|
|
1797
|
-
const content =
|
|
1443
|
+
const content = readFileSafe2(path2);
|
|
1798
1444
|
if (!content) continue;
|
|
1799
1445
|
const colorMatches = content.matchAll(
|
|
1800
1446
|
/<Color\s+x:Key="([^"]+)"\s*>\s*#([0-9a-fA-F]{6,8})\s*<\/Color>/g
|
|
@@ -1821,7 +1467,7 @@ function extractMauiColors(rootDir) {
|
|
|
1821
1467
|
}
|
|
1822
1468
|
const csFiles = findFiles(rootDir, [".cs"], 5);
|
|
1823
1469
|
for (const file of csFiles.slice(0, 50)) {
|
|
1824
|
-
const content =
|
|
1470
|
+
const content = readFileSafe2(file);
|
|
1825
1471
|
if (!content) continue;
|
|
1826
1472
|
const argbMatches = content.matchAll(
|
|
1827
1473
|
/Color\.FromArgb\s*\(\s*"#?([0-9a-fA-F]{6,8})"/g
|
|
@@ -1871,7 +1517,7 @@ function extractFlutterColors(rootDir) {
|
|
|
1871
1517
|
const dartFiles = findFiles(rootDir, [".dart"], 6);
|
|
1872
1518
|
const hexColors = [];
|
|
1873
1519
|
for (const file of dartFiles.slice(0, 50)) {
|
|
1874
|
-
const content =
|
|
1520
|
+
const content = readFileSafe2(file);
|
|
1875
1521
|
if (!content) continue;
|
|
1876
1522
|
const colorMatches = content.matchAll(/Color\(0[xX]([0-9a-fA-F]{8})\)/g);
|
|
1877
1523
|
for (const match of colorMatches) {
|
|
@@ -1923,7 +1569,7 @@ function extractJSColors(rootDir) {
|
|
|
1923
1569
|
(f) => f.endsWith(fileName)
|
|
1924
1570
|
);
|
|
1925
1571
|
for (const file of files) {
|
|
1926
|
-
const content =
|
|
1572
|
+
const content = readFileSafe2(file);
|
|
1927
1573
|
if (!content) continue;
|
|
1928
1574
|
const matches = content.matchAll(
|
|
1929
1575
|
/['"]#([0-9a-fA-F]{6})['"]/g
|
|
@@ -1977,7 +1623,7 @@ function extractSwiftColors(rootDir) {
|
|
|
1977
1623
|
}
|
|
1978
1624
|
const swiftFiles = findFiles(rootDir, [".swift"], 6);
|
|
1979
1625
|
for (const file of swiftFiles.slice(0, 50)) {
|
|
1980
|
-
const content =
|
|
1626
|
+
const content = readFileSafe2(file);
|
|
1981
1627
|
if (!content) continue;
|
|
1982
1628
|
const hexMatches = content.matchAll(
|
|
1983
1629
|
/(?:UIColor|Color)\s*\(\s*(?:hex|hexString)\s*:\s*(?:0[xX]|"#?)([0-9a-fA-F]{6})/g
|
|
@@ -2022,7 +1668,7 @@ function extractAndroidColors(rootDir) {
|
|
|
2022
1668
|
const colorMap = /* @__PURE__ */ new Map();
|
|
2023
1669
|
const colorsFiles = findAllFiles(rootDir, "colors.xml", 8);
|
|
2024
1670
|
for (const path2 of colorsFiles) {
|
|
2025
|
-
const content =
|
|
1671
|
+
const content = readFileSafe2(path2);
|
|
2026
1672
|
if (!content) continue;
|
|
2027
1673
|
const matches = content.matchAll(
|
|
2028
1674
|
/<color\s+name="([^"]+)"[^>]*>\s*#([0-9a-fA-F]{6,8})\s*<\/color>/g
|
|
@@ -2044,7 +1690,7 @@ function extractAndroidColors(rootDir) {
|
|
|
2044
1690
|
...findAllFiles(rootDir, "styles.xml", 8)
|
|
2045
1691
|
];
|
|
2046
1692
|
for (const path2 of themeFiles) {
|
|
2047
|
-
const content =
|
|
1693
|
+
const content = readFileSafe2(path2);
|
|
2048
1694
|
if (!content) continue;
|
|
2049
1695
|
const itemMatches = content.matchAll(
|
|
2050
1696
|
/<item\s+name="(?:android:)?([A-Za-z]+)"[^>]*>\s*(?:@color\/([A-Za-z0-9_]+)|#([0-9a-fA-F]{6,8}))\s*<\/item>/g
|
|
@@ -2068,7 +1714,7 @@ function extractAndroidColors(rootDir) {
|
|
|
2068
1714
|
for (const file of ktFiles.slice(0, 80)) {
|
|
2069
1715
|
const fileName = file.toLowerCase();
|
|
2070
1716
|
if (!fileName.includes("color") && !fileName.includes("theme")) continue;
|
|
2071
|
-
const content =
|
|
1717
|
+
const content = readFileSafe2(file);
|
|
2072
1718
|
if (!content) continue;
|
|
2073
1719
|
const valMap = /* @__PURE__ */ new Map();
|
|
2074
1720
|
const valMatches = content.matchAll(
|
|
@@ -2144,7 +1790,7 @@ function parseColorSetInCatalog(catalogPath, colorName) {
|
|
|
2144
1790
|
}
|
|
2145
1791
|
function parseColorSet(colorSetPath) {
|
|
2146
1792
|
const contentsPath = join5(colorSetPath, "Contents.json");
|
|
2147
|
-
const content =
|
|
1793
|
+
const content = readFileSafe2(contentsPath);
|
|
2148
1794
|
if (!content) return null;
|
|
2149
1795
|
try {
|
|
2150
1796
|
const data = JSON.parse(content);
|
|
@@ -2275,7 +1921,7 @@ function findAppIcon(rootDir, techStack) {
|
|
|
2275
1921
|
}
|
|
2276
1922
|
function resolveExpoIconPaths(rootDir) {
|
|
2277
1923
|
const paths = [];
|
|
2278
|
-
const appJsonContent =
|
|
1924
|
+
const appJsonContent = readFileSafe2(join5(rootDir, "app.json"));
|
|
2279
1925
|
if (!appJsonContent) return paths;
|
|
2280
1926
|
try {
|
|
2281
1927
|
const data = JSON.parse(appJsonContent);
|
|
@@ -2304,7 +1950,7 @@ function findIconsInAppIconSet(searchDir) {
|
|
|
2304
1950
|
const fullPath = join5(dir, entry);
|
|
2305
1951
|
if (entry === "AppIcon.appiconset") {
|
|
2306
1952
|
const contentsPath = join5(fullPath, "Contents.json");
|
|
2307
|
-
const content =
|
|
1953
|
+
const content = readFileSafe2(contentsPath);
|
|
2308
1954
|
if (content) {
|
|
2309
1955
|
try {
|
|
2310
1956
|
const data = JSON.parse(content);
|
|
@@ -2429,7 +2075,7 @@ function scanForFontFiles(dir, maxDepth = 3) {
|
|
|
2429
2075
|
function detectFlutterFonts(rootDir) {
|
|
2430
2076
|
const fonts = [];
|
|
2431
2077
|
const pubspecPath = join6(rootDir, "pubspec.yaml");
|
|
2432
|
-
const pubspecContent =
|
|
2078
|
+
const pubspecContent = readFileSafe2(pubspecPath);
|
|
2433
2079
|
if (pubspecContent) {
|
|
2434
2080
|
try {
|
|
2435
2081
|
const doc = yaml.parse(pubspecContent);
|
|
@@ -2446,7 +2092,7 @@ function detectFlutterFonts(rootDir) {
|
|
|
2446
2092
|
}
|
|
2447
2093
|
const dartFiles = findFiles(rootDir, [".dart"], 6);
|
|
2448
2094
|
for (const file of dartFiles.slice(0, 80)) {
|
|
2449
|
-
const content =
|
|
2095
|
+
const content = readFileSafe2(file);
|
|
2450
2096
|
if (!content) continue;
|
|
2451
2097
|
const fontFamilyMatches = content.matchAll(/fontFamily\s*:\s*['"]([^'"]+)['"]/g);
|
|
2452
2098
|
for (const match of fontFamilyMatches) {
|
|
@@ -2469,7 +2115,7 @@ function detectJSFonts(rootDir) {
|
|
|
2469
2115
|
const fonts = [];
|
|
2470
2116
|
const jsFiles = findFiles(rootDir, [".ts", ".tsx", ".js", ".jsx"], 5);
|
|
2471
2117
|
for (const file of jsFiles.slice(0, 100)) {
|
|
2472
|
-
const content =
|
|
2118
|
+
const content = readFileSafe2(file);
|
|
2473
2119
|
if (!content) continue;
|
|
2474
2120
|
const matches = content.matchAll(/fontFamily\s*:\s*['"]([^'"]+)['"]/g);
|
|
2475
2121
|
for (const match of matches) {
|
|
@@ -2493,7 +2139,7 @@ function detectSwiftFonts(rootDir) {
|
|
|
2493
2139
|
const fonts = [];
|
|
2494
2140
|
const swiftFiles = findFiles(rootDir, [".swift"], 6);
|
|
2495
2141
|
for (const file of swiftFiles.slice(0, 80)) {
|
|
2496
|
-
const content =
|
|
2142
|
+
const content = readFileSafe2(file);
|
|
2497
2143
|
if (!content) continue;
|
|
2498
2144
|
const uiFontMatches = content.matchAll(/UIFont\s*\(\s*name:\s*"([^"]+)"/g);
|
|
2499
2145
|
for (const match of uiFontMatches) {
|
|
@@ -2509,7 +2155,7 @@ function detectSwiftFonts(rootDir) {
|
|
|
2509
2155
|
...findPlistFiles(rootDir)
|
|
2510
2156
|
];
|
|
2511
2157
|
for (const plistPath of plistPaths) {
|
|
2512
|
-
const content =
|
|
2158
|
+
const content = readFileSafe2(plistPath);
|
|
2513
2159
|
if (!content) continue;
|
|
2514
2160
|
try {
|
|
2515
2161
|
const data = plist2.parse(content);
|
|
@@ -2576,7 +2222,7 @@ function detectAndroidFonts(rootDir) {
|
|
|
2576
2222
|
}
|
|
2577
2223
|
const xmlFiles = findFiles(rootDir, [".xml"], 5);
|
|
2578
2224
|
for (const file of xmlFiles.slice(0, 50)) {
|
|
2579
|
-
const content =
|
|
2225
|
+
const content = readFileSafe2(file);
|
|
2580
2226
|
if (!content) continue;
|
|
2581
2227
|
const matches = content.matchAll(/android:fontFamily\s*=\s*"(?:@font\/)?([^"]+)"/g);
|
|
2582
2228
|
for (const match of matches) {
|
|
@@ -2586,7 +2232,7 @@ function detectAndroidFonts(rootDir) {
|
|
|
2586
2232
|
}
|
|
2587
2233
|
const ktFiles = findFiles(rootDir, [".kt"], 6);
|
|
2588
2234
|
for (const file of ktFiles.slice(0, 80)) {
|
|
2589
|
-
const content =
|
|
2235
|
+
const content = readFileSafe2(file);
|
|
2590
2236
|
if (!content) continue;
|
|
2591
2237
|
const fontResMatches = content.matchAll(/Font\s*\(\s*R\.font\.(\w+)/g);
|
|
2592
2238
|
for (const match of fontResMatches) {
|
|
@@ -2612,7 +2258,7 @@ function detectExpoFonts(rootDir) {
|
|
|
2612
2258
|
}
|
|
2613
2259
|
const jsFiles = findFiles(rootDir, [".ts", ".tsx", ".js", ".jsx"], 6);
|
|
2614
2260
|
for (const file of jsFiles.slice(0, 100)) {
|
|
2615
|
-
const content =
|
|
2261
|
+
const content = readFileSafe2(file);
|
|
2616
2262
|
if (!content) continue;
|
|
2617
2263
|
const useFontsMatch = content.match(/useFonts\s*\(\s*\{([\s\S]*?)\}\s*\)/);
|
|
2618
2264
|
if (useFontsMatch) {
|
|
@@ -2635,7 +2281,7 @@ function detectExpoFonts(rootDir) {
|
|
|
2635
2281
|
fonts.push(m[1]);
|
|
2636
2282
|
}
|
|
2637
2283
|
}
|
|
2638
|
-
const appJsonContent =
|
|
2284
|
+
const appJsonContent = readFileSafe2(join6(rootDir, "app.json"));
|
|
2639
2285
|
if (appJsonContent) {
|
|
2640
2286
|
try {
|
|
2641
2287
|
const data = JSON.parse(appJsonContent);
|
|
@@ -2670,7 +2316,7 @@ function detectMauiFonts(rootDir) {
|
|
|
2670
2316
|
}
|
|
2671
2317
|
const csFiles = findFiles(rootDir, [".cs"], 4);
|
|
2672
2318
|
for (const file of csFiles.slice(0, 30)) {
|
|
2673
|
-
const content =
|
|
2319
|
+
const content = readFileSafe2(file);
|
|
2674
2320
|
if (!content) continue;
|
|
2675
2321
|
if (!content.includes("AddFont")) continue;
|
|
2676
2322
|
const matches = content.matchAll(
|
|
@@ -2682,7 +2328,7 @@ function detectMauiFonts(rootDir) {
|
|
|
2682
2328
|
}
|
|
2683
2329
|
const xamlFiles = findFiles(rootDir, [".xaml"], 5);
|
|
2684
2330
|
for (const file of xamlFiles.slice(0, 50)) {
|
|
2685
|
-
const content =
|
|
2331
|
+
const content = readFileSafe2(file);
|
|
2686
2332
|
if (!content) continue;
|
|
2687
2333
|
const matches = content.matchAll(/FontFamily\s*=\s*"([^"]+)"/g);
|
|
2688
2334
|
for (const match of matches) {
|
|
@@ -2734,7 +2380,7 @@ function detectFonts(rootDir, techStack) {
|
|
|
2734
2380
|
|
|
2735
2381
|
// src/analyzers/source-reader.ts
|
|
2736
2382
|
import { readdirSync as readdirSync7, statSync as statSync4 } from "fs";
|
|
2737
|
-
import { join as join7, relative as
|
|
2383
|
+
import { join as join7, relative as relative2 } from "path";
|
|
2738
2384
|
var README_NAMES = [
|
|
2739
2385
|
"README.md",
|
|
2740
2386
|
"readme.md",
|
|
@@ -2773,14 +2419,14 @@ var TREE_IGNORE = /* @__PURE__ */ new Set([
|
|
|
2773
2419
|
]);
|
|
2774
2420
|
function readReadme(rootDir) {
|
|
2775
2421
|
for (const name of README_NAMES) {
|
|
2776
|
-
const content =
|
|
2422
|
+
const content = readFileSafe2(join7(rootDir, name));
|
|
2777
2423
|
if (content && content.trim().length > 0) {
|
|
2778
2424
|
return content.substring(0, 1e4);
|
|
2779
2425
|
}
|
|
2780
2426
|
}
|
|
2781
2427
|
return null;
|
|
2782
2428
|
}
|
|
2783
|
-
function
|
|
2429
|
+
function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
2784
2430
|
const extensions = getExtensionsForStack(techStack);
|
|
2785
2431
|
const priorityPatterns = getPriorityPatternsForStack(techStack);
|
|
2786
2432
|
const allFiles = scanDirectory(rootDir, {
|
|
@@ -2789,33 +2435,27 @@ function readSourceFiles(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
2789
2435
|
maxFiles: 500
|
|
2790
2436
|
});
|
|
2791
2437
|
const sorted = sortByPriority(allFiles, priorityPatterns);
|
|
2792
|
-
const
|
|
2438
|
+
const parts = [];
|
|
2793
2439
|
let totalChars = 0;
|
|
2794
2440
|
for (const file of sorted) {
|
|
2795
2441
|
if (totalChars >= maxTotalChars) break;
|
|
2796
|
-
const content =
|
|
2442
|
+
const content = readFileSafe2(join7(rootDir, file));
|
|
2797
2443
|
if (!content || content.trim().length < 20) continue;
|
|
2798
2444
|
if (isTestFile(file)) continue;
|
|
2799
2445
|
if (isGeneratedFile(file, content)) continue;
|
|
2800
2446
|
const truncated = content.substring(0, 15e3);
|
|
2801
|
-
|
|
2447
|
+
parts.push(`--- ${file} ---
|
|
2448
|
+
${truncated}`);
|
|
2802
2449
|
totalChars += truncated.length;
|
|
2803
2450
|
}
|
|
2804
|
-
return
|
|
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));
|
|
2451
|
+
return parts.join("\n\n");
|
|
2812
2452
|
}
|
|
2813
2453
|
function getExtensionsForStack(techStack) {
|
|
2814
2454
|
switch (techStack) {
|
|
2815
2455
|
case "flutter":
|
|
2816
2456
|
return [".dart"];
|
|
2817
2457
|
case "swift":
|
|
2818
|
-
return [".swift"
|
|
2458
|
+
return [".swift"];
|
|
2819
2459
|
case "kotlin":
|
|
2820
2460
|
return [".kt", ".kts"];
|
|
2821
2461
|
case "expo":
|
|
@@ -2824,10 +2464,6 @@ function getExtensionsForStack(techStack) {
|
|
|
2824
2464
|
return [".ts", ".tsx", ".js", ".jsx"];
|
|
2825
2465
|
case "dotnet-maui":
|
|
2826
2466
|
return [".cs", ".xaml"];
|
|
2827
|
-
case "unity":
|
|
2828
|
-
return [".cs"];
|
|
2829
|
-
case "kmp":
|
|
2830
|
-
return [".kt", ".kts", ".swift"];
|
|
2831
2467
|
default:
|
|
2832
2468
|
return [".ts", ".js", ".swift", ".dart", ".kt"];
|
|
2833
2469
|
}
|
|
@@ -2877,10 +2513,6 @@ function getPriorityPatternsForStack(techStack) {
|
|
|
2877
2513
|
return [...common, "screen", "component", "hook", "context", "store", "slice"];
|
|
2878
2514
|
case "dotnet-maui":
|
|
2879
2515
|
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"];
|
|
2884
2516
|
default:
|
|
2885
2517
|
return common;
|
|
2886
2518
|
}
|
|
@@ -2969,7 +2601,7 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
2969
2601
|
return false;
|
|
2970
2602
|
}
|
|
2971
2603
|
}
|
|
2972
|
-
const rootName =
|
|
2604
|
+
const rootName = relative2(join7(rootDir, ".."), rootDir) || "project";
|
|
2973
2605
|
lines.push(`${rootName}/`);
|
|
2974
2606
|
walk(rootDir, "", 0);
|
|
2975
2607
|
return lines.join("\n");
|
|
@@ -2977,7 +2609,7 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
2977
2609
|
|
|
2978
2610
|
// src/analyzers/asset-scanner.ts
|
|
2979
2611
|
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
|
|
2612
|
+
import { join as join8, extname as extname3, relative as relative3, basename as basename2 } from "path";
|
|
2981
2613
|
var MAX_ASSET_SIZE = 5 * 1024 * 1024;
|
|
2982
2614
|
var MAX_TOTAL_ASSETS = 10;
|
|
2983
2615
|
var MAX_SCREENSHOTS = 5;
|
|
@@ -3008,7 +2640,7 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
|
|
|
3008
2640
|
base64_data: buffer.toString("base64"),
|
|
3009
2641
|
width: null,
|
|
3010
2642
|
height: null,
|
|
3011
|
-
source_path:
|
|
2643
|
+
source_path: relative3(rootDir, filePath)
|
|
3012
2644
|
};
|
|
3013
2645
|
} catch {
|
|
3014
2646
|
return null;
|
|
@@ -3627,7 +3259,7 @@ async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
|
|
|
3627
3259
|
mime_type: mimeForPath(appIconPath),
|
|
3628
3260
|
width: null,
|
|
3629
3261
|
height: null,
|
|
3630
|
-
source_path:
|
|
3262
|
+
source_path: relative4(rootDir, appIconPath)
|
|
3631
3263
|
},
|
|
3632
3264
|
buffer
|
|
3633
3265
|
});
|
|
@@ -4168,1668 +3800,143 @@ import { createInterface as createInterface2 } from "readline";
|
|
|
4168
3800
|
import chalk3 from "chalk";
|
|
4169
3801
|
import ora2 from "ora";
|
|
4170
3802
|
|
|
4171
|
-
// src/
|
|
4172
|
-
|
|
3803
|
+
// src/review/feature-detector.ts
|
|
3804
|
+
function getFeatureDiscoveryPrompt() {
|
|
3805
|
+
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.
|
|
4173
3806
|
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
|
|
4188
|
-
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
4192
|
-
|
|
4193
|
-
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
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
|
-
});
|
|
3807
|
+
Respond in English.
|
|
3808
|
+
|
|
3809
|
+
Answer this question: "If someone downloads this app and opens it on their iPhone, what will they see and experience?"
|
|
3810
|
+
|
|
3811
|
+
Analyze the source code and identify:
|
|
3812
|
+
|
|
3813
|
+
1. **App Purpose**: What does this app do? (1-2 sentences)
|
|
3814
|
+
2. **User Flows**: What are the main things a user can do? (list each)
|
|
3815
|
+
3. **Onboarding**: What happens when you first open the app? Login required? Permissions asked immediately?
|
|
3816
|
+
4. **Monetization**: How does the app make money? Subscriptions? IAP? Ads? External payments? Free?
|
|
3817
|
+
5. **Data & Privacy**: What user data does the app collect? What device permissions does it need? Is there a privacy policy link?
|
|
3818
|
+
6. **Content**: Is there user-generated content (posts, comments, chat)? Third-party content? Web content?
|
|
3819
|
+
7. **Account Management**: Can users create accounts? Can they delete their accounts?
|
|
3820
|
+
8. **Platform Features**: Does it use WebViews, widgets, extensions, push notifications, background processing?
|
|
3821
|
+
9. **Settings & Legal**: What can users configure? Is there a privacy policy accessible in-app? Terms of service?
|
|
3822
|
+
10. **External Links**: Does the app link to external payment pages, websites, or other apps?
|
|
3823
|
+
|
|
3824
|
+
IMPORTANT: You are seeing a SUBSET of the source code, not the entire codebase. Do not make definitive claims about features being absent \u2014 only report features you can positively identify from the code shown. If something might exist but you don't see it, do not list it as missing.
|
|
3825
|
+
|
|
3826
|
+
Respond ONLY with valid JSON in this exact format:
|
|
3827
|
+
|
|
3828
|
+
{
|
|
3829
|
+
"appPurpose": "string describing what the app does",
|
|
3830
|
+
"features": [
|
|
3831
|
+
{
|
|
3832
|
+
"feature": "string identifier (e.g., 'subscriptions', 'webview', 'ugc', 'account_creation', 'push_notifications', 'camera_usage', 'location_tracking')",
|
|
3833
|
+
"description": "string describing what the user experiences",
|
|
3834
|
+
"confidence": 0.0-1.0,
|
|
3835
|
+
"keywords": ["relevant", "keywords", "from", "code"],
|
|
3836
|
+
"category": "safety|performance|business|design|legal|common-pitfalls",
|
|
3837
|
+
"relevantFiles": ["file1.swift", "file2.ts"]
|
|
4249
3838
|
}
|
|
4250
|
-
|
|
4251
|
-
|
|
3839
|
+
],
|
|
3840
|
+
"riskAreas": ["string describing areas that Apple reviewers commonly flag for apps like this"],
|
|
3841
|
+
"appType": "native|hybrid|webview-heavy|web-wrapper"
|
|
3842
|
+
}`;
|
|
4252
3843
|
}
|
|
3844
|
+
async function detectFeatures(provider, platform, appName, bundleId, sourceCode) {
|
|
3845
|
+
const systemPrompt = getFeatureDiscoveryPrompt();
|
|
3846
|
+
const userPrompt = `## App Information
|
|
3847
|
+
- Platform: ${platform}
|
|
3848
|
+
- App Name: ${appName}
|
|
3849
|
+
- Bundle ID: ${bundleId}
|
|
4253
3850
|
|
|
4254
|
-
|
|
4255
|
-
import { join as join9, relative as relative6 } from "path";
|
|
4256
|
-
import plistLib from "plist";
|
|
3851
|
+
## Source Code
|
|
4257
3852
|
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
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;
|
|
3853
|
+
${sourceCode}`;
|
|
3854
|
+
try {
|
|
3855
|
+
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
3856
|
+
return parseFeatureAnalysis(response);
|
|
3857
|
+
} catch {
|
|
3858
|
+
return null;
|
|
4306
3859
|
}
|
|
4307
|
-
return null;
|
|
4308
3860
|
}
|
|
4309
|
-
function
|
|
4310
|
-
return relPath.split(/[\\/]/).length;
|
|
4311
|
-
}
|
|
4312
|
-
|
|
4313
|
-
// src/engine/extractors/ios-plist.ts
|
|
4314
|
-
var NAMED_FIELD_KEYS = /* @__PURE__ */ new Set([
|
|
4315
|
-
"UIBackgroundModes",
|
|
4316
|
-
"NSAppTransportSecurity",
|
|
4317
|
-
"GADApplicationIdentifier",
|
|
4318
|
-
"SKAdNetworkItems",
|
|
4319
|
-
"ITSAppUsesNonExemptEncryption",
|
|
4320
|
-
"UIRequiredDeviceCapabilities"
|
|
4321
|
-
]);
|
|
4322
|
-
function findMainInfoPlist(rootDir) {
|
|
4323
|
-
const candidates = findAllFiles(rootDir, "Info.plist", 4);
|
|
4324
|
-
const nonExtension = candidates.filter(
|
|
4325
|
-
(abs) => !isExtensionPath(relative6(rootDir, abs))
|
|
4326
|
-
);
|
|
4327
|
-
if (nonExtension.length === 0) return null;
|
|
4328
|
-
return [...nonExtension].sort(
|
|
4329
|
-
(a, b) => pathDepth(relative6(rootDir, a)) - pathDepth(relative6(rootDir, b))
|
|
4330
|
-
)[0];
|
|
4331
|
-
}
|
|
4332
|
-
function readInfoPlistFile(filePath) {
|
|
4333
|
-
const content = readFileSafe(filePath);
|
|
4334
|
-
if (!content) return null;
|
|
3861
|
+
function parseFeatureAnalysis(response) {
|
|
4335
3862
|
try {
|
|
4336
|
-
|
|
4337
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
4338
|
-
return parsed;
|
|
4339
|
-
}
|
|
4340
|
-
return null;
|
|
3863
|
+
return JSON.parse(response);
|
|
4341
3864
|
} catch {
|
|
3865
|
+
const jsonMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
3866
|
+
if (jsonMatch) {
|
|
3867
|
+
try {
|
|
3868
|
+
return JSON.parse(jsonMatch[1]);
|
|
3869
|
+
} catch {
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
const objectMatch = response.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
|
3873
|
+
if (objectMatch) {
|
|
3874
|
+
try {
|
|
3875
|
+
return JSON.parse(objectMatch[0]);
|
|
3876
|
+
} catch {
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
4342
3879
|
return null;
|
|
4343
3880
|
}
|
|
4344
3881
|
}
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
const
|
|
4361
|
-
|
|
4362
|
-
const
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4370
|
-
|
|
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?
|
|
5749
|
-
5. **Data & Privacy**: What user data does the app collect? What device permissions does it need? Is there a privacy policy link?
|
|
5750
|
-
6. **Content**: Is there user-generated content (posts, comments, chat)? Third-party content? Web content?
|
|
5751
|
-
7. **Account Management**: Can users create accounts? Can they delete their accounts?
|
|
5752
|
-
8. **Platform Features**: Does it use WebViews, widgets, extensions, push notifications, background processing?
|
|
5753
|
-
9. **Settings & Legal**: What can users configure? Is there a privacy policy accessible in-app? Terms of service?
|
|
5754
|
-
10. **External Links**: Does the app link to external payment pages, websites, or other apps?
|
|
5755
|
-
|
|
5756
|
-
IMPORTANT: You are seeing a SUBSET of the source code, not the entire codebase. Do not make definitive claims about features being absent \u2014 only report features you can positively identify from the code shown. If something might exist but you don't see it, do not list it as missing.
|
|
5757
|
-
|
|
5758
|
-
Respond ONLY with valid JSON in this exact format:
|
|
5759
|
-
|
|
5760
|
-
{
|
|
5761
|
-
"appPurpose": "string describing what the app does",
|
|
5762
|
-
"features": [
|
|
5763
|
-
{
|
|
5764
|
-
"feature": "string identifier (e.g., 'subscriptions', 'webview', 'ugc', 'account_creation', 'push_notifications', 'camera_usage', 'location_tracking')",
|
|
5765
|
-
"description": "string describing what the user experiences",
|
|
5766
|
-
"confidence": 0.0-1.0,
|
|
5767
|
-
"keywords": ["relevant", "keywords", "from", "code"],
|
|
5768
|
-
"category": "safety|performance|business|design|legal|common-pitfalls",
|
|
5769
|
-
"relevantFiles": ["file1.swift", "file2.ts"]
|
|
5770
|
-
}
|
|
5771
|
-
],
|
|
5772
|
-
"riskAreas": ["string describing areas that Apple reviewers commonly flag for apps like this"],
|
|
5773
|
-
"appType": "native|hybrid|webview-heavy|web-wrapper"
|
|
5774
|
-
}`;
|
|
5775
|
-
}
|
|
5776
|
-
async function detectFeatures(provider, platform, appName, bundleId, sourceCode) {
|
|
5777
|
-
const systemPrompt = getFeatureDiscoveryPrompt();
|
|
5778
|
-
const userPrompt = `## App Information
|
|
5779
|
-
- Platform: ${platform}
|
|
5780
|
-
- App Name: ${appName}
|
|
5781
|
-
- Bundle ID: ${bundleId}
|
|
5782
|
-
|
|
5783
|
-
## Source Code
|
|
5784
|
-
|
|
5785
|
-
${sourceCode}`;
|
|
5786
|
-
try {
|
|
5787
|
-
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
5788
|
-
return parseFeatureAnalysis(response);
|
|
5789
|
-
} catch {
|
|
5790
|
-
return null;
|
|
5791
|
-
}
|
|
5792
|
-
}
|
|
5793
|
-
function parseFeatureAnalysis(response) {
|
|
5794
|
-
try {
|
|
5795
|
-
return JSON.parse(response);
|
|
5796
|
-
} catch {
|
|
5797
|
-
const jsonMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
5798
|
-
if (jsonMatch) {
|
|
5799
|
-
try {
|
|
5800
|
-
return JSON.parse(jsonMatch[1]);
|
|
5801
|
-
} catch {
|
|
5802
|
-
}
|
|
5803
|
-
}
|
|
5804
|
-
const objectMatch = response.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
|
5805
|
-
if (objectMatch) {
|
|
5806
|
-
try {
|
|
5807
|
-
return JSON.parse(objectMatch[0]);
|
|
5808
|
-
} catch {
|
|
5809
|
-
}
|
|
3882
|
+
|
|
3883
|
+
// src/review/rag-selector.ts
|
|
3884
|
+
import { readFileSync as readFileSync6 } from "fs";
|
|
3885
|
+
import { fileURLToPath } from "url";
|
|
3886
|
+
import path from "path";
|
|
3887
|
+
var DATA_FILES = [
|
|
3888
|
+
"safety.json",
|
|
3889
|
+
"performance.json",
|
|
3890
|
+
"business.json",
|
|
3891
|
+
"design.json",
|
|
3892
|
+
"legal.json",
|
|
3893
|
+
"common-pitfalls.json"
|
|
3894
|
+
];
|
|
3895
|
+
var cachedStories = null;
|
|
3896
|
+
function getDataDir() {
|
|
3897
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
3898
|
+
const __dirname = path.dirname(__filename);
|
|
3899
|
+
const candidates = [
|
|
3900
|
+
path.join(__dirname, "rag-data"),
|
|
3901
|
+
path.join(__dirname, "review", "rag-data"),
|
|
3902
|
+
path.join(__dirname, "..", "review", "rag-data"),
|
|
3903
|
+
path.join(__dirname, "..", "src", "review", "rag-data")
|
|
3904
|
+
];
|
|
3905
|
+
for (const dir of candidates) {
|
|
3906
|
+
try {
|
|
3907
|
+
readFileSync6(path.join(dir, "safety.json"));
|
|
3908
|
+
return dir;
|
|
3909
|
+
} catch {
|
|
5810
3910
|
}
|
|
5811
|
-
return null;
|
|
5812
3911
|
}
|
|
3912
|
+
return candidates[0];
|
|
5813
3913
|
}
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
const
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
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);
|
|
3914
|
+
function loadAllStories() {
|
|
3915
|
+
if (cachedStories) return cachedStories;
|
|
3916
|
+
const dataDir = getDataDir();
|
|
3917
|
+
const stories = [];
|
|
3918
|
+
for (const file of DATA_FILES) {
|
|
3919
|
+
try {
|
|
3920
|
+
const content = readFileSync6(path.join(dataDir, file), "utf-8");
|
|
3921
|
+
const parsed = JSON.parse(content);
|
|
3922
|
+
stories.push(...parsed);
|
|
3923
|
+
} catch {
|
|
5830
3924
|
}
|
|
5831
3925
|
}
|
|
5832
|
-
|
|
3926
|
+
cachedStories = stories;
|
|
3927
|
+
return stories;
|
|
3928
|
+
}
|
|
3929
|
+
function searchStories(signals, maxResults = 20) {
|
|
3930
|
+
const allStories = loadAllStories();
|
|
3931
|
+
if (signals.length === 0) return allStories.slice(0, maxResults);
|
|
3932
|
+
const signalKeywords = new Set(
|
|
3933
|
+
signals.flatMap((s) => s.keywords.map((k) => k.toLowerCase()))
|
|
3934
|
+
);
|
|
3935
|
+
const signalFeatures = new Set(
|
|
3936
|
+
signals.map((s) => s.feature.toLowerCase())
|
|
3937
|
+
);
|
|
3938
|
+
const signalCategories = new Set(signals.map((s) => s.category));
|
|
3939
|
+
const scored = allStories.map((story) => {
|
|
5833
3940
|
let score = 0;
|
|
5834
3941
|
for (const signal of story.behavioralSignals) {
|
|
5835
3942
|
const signalLower = signal.toLowerCase();
|
|
@@ -5876,21 +3983,6 @@ You do NOT review code quality. You review the APP \u2014 what a user sees, touc
|
|
|
5876
3983
|
For every issue, think through this exact scenario:
|
|
5877
3984
|
"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]."
|
|
5878
3985
|
|
|
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
|
-
|
|
5894
3986
|
## WHAT YOU CHECK (as if using the app)
|
|
5895
3987
|
|
|
5896
3988
|
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?
|
|
@@ -5941,11 +4033,11 @@ You MUST respond with valid JSON:
|
|
|
5941
4033
|
"relatedRejection": "string | null (reference to a similar rejection case if provided)"
|
|
5942
4034
|
}
|
|
5943
4035
|
],
|
|
5944
|
-
"overallAssessment": "string (2-3 sentence assessment of the app's review readiness)"
|
|
4036
|
+
"overallAssessment": "string (2-3 sentence assessment of the app's review readiness)",
|
|
4037
|
+
"estimatedOutcome": "likely-approved | likely-rejected | needs-attention",
|
|
4038
|
+
"estimatedApprovalChance": 0-100
|
|
5945
4039
|
}
|
|
5946
4040
|
|
|
5947
|
-
Do not include an approval estimate; the final outcome is computed deterministically from the merged findings.
|
|
5948
|
-
|
|
5949
4041
|
## IMPORTANT RULES
|
|
5950
4042
|
|
|
5951
4043
|
1. Every finding MUST describe user-visible behavior, not code patterns
|
|
@@ -5995,8 +4087,14 @@ function validateCategory(c) {
|
|
|
5995
4087
|
}
|
|
5996
4088
|
return "performance";
|
|
5997
4089
|
}
|
|
4090
|
+
function validateOutcome(o) {
|
|
4091
|
+
if (["likely-approved", "likely-rejected", "needs-attention"].includes(o)) {
|
|
4092
|
+
return o;
|
|
4093
|
+
}
|
|
4094
|
+
return "needs-attention";
|
|
4095
|
+
}
|
|
5998
4096
|
function convertFindings(raw) {
|
|
5999
|
-
return
|
|
4097
|
+
return raw.findings.map((f, i) => ({
|
|
6000
4098
|
id: f.ruleId || `finding-${i + 1}`,
|
|
6001
4099
|
title: f.title,
|
|
6002
4100
|
risk_level: validateRiskLevel(f.severity),
|
|
@@ -6009,164 +4107,51 @@ function convertFindings(raw) {
|
|
|
6009
4107
|
evidence: f.evidence ?? "",
|
|
6010
4108
|
recommendation: f.fixSuggestion,
|
|
6011
4109
|
file: f.file ?? void 0,
|
|
6012
|
-
related_story_id: f.relatedRejection ?? void 0
|
|
6013
|
-
// No rule_id: AI findings have no backing rulepack rule.
|
|
6014
|
-
detection: "ai"
|
|
4110
|
+
related_story_id: f.relatedRejection ?? void 0
|
|
6015
4111
|
}));
|
|
6016
4112
|
}
|
|
6017
|
-
function
|
|
6018
|
-
|
|
6019
|
-
|
|
6020
|
-
|
|
6021
|
-
const
|
|
6022
|
-
|
|
6023
|
-
|
|
6024
|
-
|
|
6025
|
-
|
|
6026
|
-
|
|
6027
|
-
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
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");
|
|
4113
|
+
function buildSummary(findings, aiResponse) {
|
|
4114
|
+
const high = findings.filter((f) => f.risk_level === "high").length;
|
|
4115
|
+
const medium = findings.filter((f) => f.risk_level === "medium").length;
|
|
4116
|
+
const low = findings.filter((f) => f.risk_level === "low").length;
|
|
4117
|
+
const info = findings.filter((f) => f.risk_level === "info").length;
|
|
4118
|
+
const outcome = validateOutcome(aiResponse.estimatedOutcome);
|
|
4119
|
+
let approvalChance = aiResponse.estimatedApprovalChance ?? 75;
|
|
4120
|
+
if (outcome === "likely-rejected") approvalChance = Math.min(approvalChance, 30);
|
|
4121
|
+
if (outcome === "likely-approved") approvalChance = Math.max(approvalChance, 70);
|
|
4122
|
+
const topRisks = findings.filter((f) => f.risk_level === "high" || f.risk_level === "medium").slice(0, 3).map((f) => f.title);
|
|
4123
|
+
return {
|
|
4124
|
+
total_findings: findings.length,
|
|
4125
|
+
high_risk: high,
|
|
4126
|
+
medium_risk: medium,
|
|
4127
|
+
low_risk: low,
|
|
4128
|
+
info,
|
|
4129
|
+
outcome,
|
|
4130
|
+
estimated_approval_chance: approvalChance,
|
|
4131
|
+
top_risks: topRisks,
|
|
4132
|
+
ai_assessment: aiResponse.overallAssessment
|
|
4133
|
+
};
|
|
6136
4134
|
}
|
|
6137
|
-
async function runCodeReview(
|
|
6138
|
-
|
|
6139
|
-
let checks = input.staticChecks;
|
|
6140
|
-
let staticFindings = input.staticFindings;
|
|
4135
|
+
async function runCodeReview(provider, platform, appName, bundleId, sourceCode, sourceFilesCount, deep) {
|
|
4136
|
+
const startTime = Date.now();
|
|
6141
4137
|
let featureAnalysis = null;
|
|
6142
|
-
|
|
4138
|
+
let relevantStories;
|
|
4139
|
+
if (deep) {
|
|
6143
4140
|
featureAnalysis = await detectFeatures(
|
|
6144
|
-
|
|
6145
|
-
|
|
6146
|
-
|
|
6147
|
-
|
|
6148
|
-
|
|
4141
|
+
provider,
|
|
4142
|
+
platform,
|
|
4143
|
+
appName,
|
|
4144
|
+
bundleId,
|
|
4145
|
+
sourceCode
|
|
6149
4146
|
);
|
|
6150
4147
|
if (featureAnalysis) {
|
|
6151
|
-
|
|
6152
|
-
|
|
6153
|
-
|
|
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
|
-
}
|
|
4148
|
+
relevantStories = searchStories(featureAnalysis.features, 20);
|
|
4149
|
+
} else {
|
|
4150
|
+
relevantStories = loadAllStories().slice(0, 15);
|
|
6162
4151
|
}
|
|
4152
|
+
} else {
|
|
4153
|
+
relevantStories = loadAllStories().slice(0, 15);
|
|
6163
4154
|
}
|
|
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);
|
|
6170
4155
|
const storiesText = formatStoriesForPrompt(relevantStories);
|
|
6171
4156
|
const featureSection = featureAnalysis ? `## App Analysis (from feature discovery)
|
|
6172
4157
|
|
|
@@ -6177,136 +4162,60 @@ async function runCodeReview(input) {
|
|
|
6177
4162
|
${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confidence: ${f.confidence})`).join("\n")}
|
|
6178
4163
|
|
|
6179
4164
|
**Risk Areas:**
|
|
6180
|
-
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
|
|
6181
|
-
|
|
6182
|
-
` : "";
|
|
4165
|
+
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}` : "";
|
|
6183
4166
|
const userPrompt = `## App Information
|
|
6184
|
-
- Platform: ${
|
|
6185
|
-
- App Name: ${
|
|
6186
|
-
- Bundle ID: ${
|
|
6187
|
-
|
|
6188
|
-
## App Profile (from static analysis)
|
|
6189
|
-
${formatProfileDigest(profile)}
|
|
6190
|
-
|
|
6191
|
-
## Already Detected By Static Analysis (do not repeat)
|
|
6192
|
-
These violations are already confirmed and included in the report. Do NOT re-report them:
|
|
4167
|
+
- Platform: ${platform}
|
|
4168
|
+
- App Name: ${appName}
|
|
4169
|
+
- Bundle ID: ${bundleId}
|
|
6193
4170
|
|
|
6194
|
-
${
|
|
4171
|
+
${featureSection}
|
|
6195
4172
|
|
|
6196
|
-
|
|
4173
|
+
## Real Rejection Cases (from other developers)
|
|
6197
4174
|
These are REAL cases where Apple rejected similar apps. Use them to calibrate:
|
|
6198
4175
|
|
|
6199
4176
|
${storiesText}
|
|
6200
4177
|
|
|
6201
|
-
## Source Code
|
|
4178
|
+
## Source Code
|
|
6202
4179
|
|
|
6203
|
-
${
|
|
4180
|
+
${sourceCode}
|
|
6204
4181
|
|
|
6205
|
-
Now review this app as an Apple reviewer testing it on a device.
|
|
4182
|
+
Now review this app as an Apple reviewer testing it on a device. What would you flag?`;
|
|
6206
4183
|
const systemPrompt = getReviewerSimulationPrompt();
|
|
6207
|
-
const response = await
|
|
4184
|
+
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
6208
4185
|
const parsed = parseAIResponse(response);
|
|
6209
|
-
const featuresDetected = featureAnalysis?.features.map((f) => f.feature) ?? [];
|
|
6210
4186
|
if (!parsed) {
|
|
6211
4187
|
return {
|
|
6212
|
-
|
|
6213
|
-
|
|
6214
|
-
|
|
6215
|
-
|
|
6216
|
-
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
4188
|
+
features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
|
|
4189
|
+
source_files_analyzed: sourceFilesCount,
|
|
4190
|
+
platform,
|
|
4191
|
+
bundle_id: bundleId || null,
|
|
4192
|
+
findings: [],
|
|
4193
|
+
summary: {
|
|
4194
|
+
total_findings: 0,
|
|
4195
|
+
high_risk: 0,
|
|
4196
|
+
medium_risk: 0,
|
|
4197
|
+
low_risk: 0,
|
|
4198
|
+
info: 0,
|
|
4199
|
+
outcome: "needs-attention",
|
|
4200
|
+
estimated_approval_chance: 50,
|
|
4201
|
+
top_risks: [],
|
|
4202
|
+
ai_assessment: "AI analysis could not produce structured results."
|
|
4203
|
+
},
|
|
4204
|
+
rag_stories_used: relevantStories.length,
|
|
4205
|
+
scan_duration_ms: Date.now() - startTime
|
|
6220
4206
|
};
|
|
6221
4207
|
}
|
|
6222
|
-
const
|
|
6223
|
-
const
|
|
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
|
-
});
|
|
4208
|
+
const findings = convertFindings(parsed);
|
|
4209
|
+
const summary = buildSummary(findings, parsed);
|
|
6293
4210
|
return {
|
|
6294
|
-
features_detected:
|
|
6295
|
-
source_files_analyzed:
|
|
6296
|
-
platform
|
|
6297
|
-
bundle_id:
|
|
4211
|
+
features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
|
|
4212
|
+
source_files_analyzed: sourceFilesCount,
|
|
4213
|
+
platform,
|
|
4214
|
+
bundle_id: bundleId || null,
|
|
6298
4215
|
findings,
|
|
6299
4216
|
summary,
|
|
6300
|
-
rag_stories_used:
|
|
6301
|
-
scan_duration_ms:
|
|
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
|
|
4217
|
+
rag_stories_used: relevantStories.length,
|
|
4218
|
+
scan_duration_ms: Date.now() - startTime
|
|
6310
4219
|
};
|
|
6311
4220
|
}
|
|
6312
4221
|
|
|
@@ -6330,7 +4239,6 @@ var RISK_COLORS = {
|
|
|
6330
4239
|
low: chalk2.blue,
|
|
6331
4240
|
info: chalk2.gray
|
|
6332
4241
|
};
|
|
6333
|
-
var STATIC_ONLY_BANNER = "AI behavioral review skipped. Static rule results only.";
|
|
6334
4242
|
function outcomeDisplay(outcome) {
|
|
6335
4243
|
switch (outcome) {
|
|
6336
4244
|
case "likely-approved":
|
|
@@ -6353,30 +4261,6 @@ function approvalBar(chance) {
|
|
|
6353
4261
|
else color = chalk2.red;
|
|
6354
4262
|
return color("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty)) + ` ${chance}%`;
|
|
6355
4263
|
}
|
|
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
|
-
}
|
|
6380
4264
|
function formatTerminal(report) {
|
|
6381
4265
|
const lines = [];
|
|
6382
4266
|
lines.push("");
|
|
@@ -6400,9 +4284,7 @@ function formatTerminal(report) {
|
|
|
6400
4284
|
report.summary.low_risk > 0 && chalk2.blue(`${report.summary.low_risk} low`),
|
|
6401
4285
|
report.summary.info > 0 && chalk2.gray(`${report.summary.info} info`)
|
|
6402
4286
|
].filter(Boolean);
|
|
6403
|
-
lines.push(
|
|
6404
|
-
` Findings: ${chalk2.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
|
|
6405
|
-
);
|
|
4287
|
+
lines.push(` Findings: ${chalk2.bold(String(report.summary.total_findings))} total \u2014 ${counts.join(" \xB7 ")}`);
|
|
6406
4288
|
lines.push("");
|
|
6407
4289
|
if (report.features_detected.length > 0) {
|
|
6408
4290
|
lines.push(` Features: ${chalk2.gray(report.features_detected.join(", "))}`);
|
|
@@ -6414,29 +4296,6 @@ function formatTerminal(report) {
|
|
|
6414
4296
|
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"));
|
|
6415
4297
|
lines.push("");
|
|
6416
4298
|
}
|
|
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
|
-
}
|
|
6440
4299
|
const findingsByRisk = {
|
|
6441
4300
|
high: [],
|
|
6442
4301
|
medium: [],
|
|
@@ -6454,10 +4313,10 @@ function formatTerminal(report) {
|
|
|
6454
4313
|
const label = RISK_LABELS[level];
|
|
6455
4314
|
const color = RISK_COLORS[level];
|
|
6456
4315
|
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"));
|
|
6457
|
-
lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number}
|
|
4316
|
+
lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
|
|
6458
4317
|
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"));
|
|
6459
4318
|
lines.push("");
|
|
6460
|
-
lines.push(` ${
|
|
4319
|
+
lines.push(` ${chalk2.bold(finding.title)}`);
|
|
6461
4320
|
lines.push(` ${finding.description}`);
|
|
6462
4321
|
lines.push("");
|
|
6463
4322
|
lines.push(` ${chalk2.cyan("User Impact:")} ${finding.user_impact}`);
|
|
@@ -6476,14 +4335,7 @@ function formatTerminal(report) {
|
|
|
6476
4335
|
lines.push("");
|
|
6477
4336
|
}
|
|
6478
4337
|
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"));
|
|
6479
|
-
|
|
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)"));
|
|
4338
|
+
lines.push(chalk2.gray(" Powered by Forvibe CLI \u2014 forvibe.app"));
|
|
6487
4339
|
lines.push("");
|
|
6488
4340
|
return lines.join("\n");
|
|
6489
4341
|
}
|
|
@@ -6502,10 +4354,6 @@ function formatMarkdown(report) {
|
|
|
6502
4354
|
lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
|
|
6503
4355
|
lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
|
|
6504
4356
|
lines.push("");
|
|
6505
|
-
if (report.ai_review_ran === false) {
|
|
6506
|
-
lines.push(`> ${STATIC_ONLY_BANNER}`);
|
|
6507
|
-
lines.push("");
|
|
6508
|
-
}
|
|
6509
4357
|
lines.push("## Result");
|
|
6510
4358
|
lines.push("");
|
|
6511
4359
|
lines.push(`**Outcome:** ${report.summary.outcome.replace(/-/g, " ").toUpperCase()}`);
|
|
@@ -6523,26 +4371,6 @@ function formatMarkdown(report) {
|
|
|
6523
4371
|
lines.push(`> ${report.summary.ai_assessment}`);
|
|
6524
4372
|
lines.push("");
|
|
6525
4373
|
}
|
|
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
|
-
}
|
|
6546
4374
|
if (report.features_detected.length > 0) {
|
|
6547
4375
|
lines.push("## Detected Features");
|
|
6548
4376
|
lines.push("");
|
|
@@ -6556,15 +4384,13 @@ function formatMarkdown(report) {
|
|
|
6556
4384
|
if (report.findings.length === 0) {
|
|
6557
4385
|
lines.push("No significant issues found. Your app looks ready for review!");
|
|
6558
4386
|
lines.push("");
|
|
6559
|
-
appendMarkdownFooter(lines, report);
|
|
6560
4387
|
return lines.join("\n");
|
|
6561
4388
|
}
|
|
6562
4389
|
for (const finding of report.findings) {
|
|
6563
4390
|
const riskEmoji = RISK_ICONS[finding.risk_level];
|
|
6564
|
-
|
|
6565
|
-
lines.push(`### ${riskEmoji} ${badge}${finding.title}`);
|
|
4391
|
+
lines.push(`### ${riskEmoji} ${finding.title}`);
|
|
6566
4392
|
lines.push("");
|
|
6567
|
-
lines.push(`**Guideline:** ${finding.guideline_number}
|
|
4393
|
+
lines.push(`**Guideline:** ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
|
|
6568
4394
|
lines.push(`**Risk Level:** ${finding.risk_level}`);
|
|
6569
4395
|
lines.push(`**Category:** ${finding.category}`);
|
|
6570
4396
|
lines.push("");
|
|
@@ -6581,36 +4407,12 @@ function formatMarkdown(report) {
|
|
|
6581
4407
|
lines.push("---");
|
|
6582
4408
|
lines.push("");
|
|
6583
4409
|
}
|
|
6584
|
-
|
|
6585
|
-
return lines.join("\n");
|
|
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)*");
|
|
4410
|
+
lines.push("*Powered by Forvibe CLI \u2014 forvibe.app*");
|
|
6594
4411
|
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;
|
|
4412
|
+
return lines.join("\n");
|
|
6610
4413
|
}
|
|
6611
4414
|
|
|
6612
4415
|
// src/commands/review.ts
|
|
6613
|
-
var DEFAULT_RULEPACK_BASE_URL = "https://forvibe.app";
|
|
6614
4416
|
function askQuestion2(question) {
|
|
6615
4417
|
const rl = createInterface2({
|
|
6616
4418
|
input: process.stdin,
|
|
@@ -6626,14 +4428,55 @@ function askQuestion2(question) {
|
|
|
6626
4428
|
async function reviewCommand(options) {
|
|
6627
4429
|
const rootDir = options.dir || process.cwd();
|
|
6628
4430
|
const deep = options.deep ?? false;
|
|
6629
|
-
const staticOnly = options.staticOnly ?? false;
|
|
6630
4431
|
const format = options.format || "terminal";
|
|
6631
|
-
const startTime = Date.now();
|
|
6632
4432
|
console.log();
|
|
6633
4433
|
console.log(
|
|
6634
|
-
chalk3.bold(" Forvibe CLI") + chalk3.gray("
|
|
4434
|
+
chalk3.bold(" Forvibe CLI") + chalk3.gray(" \u2014 App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "")
|
|
6635
4435
|
);
|
|
6636
4436
|
console.log();
|
|
4437
|
+
const availableProviders = getAvailableProviders(deep);
|
|
4438
|
+
if (availableProviders.length === 0) {
|
|
4439
|
+
console.log(chalk3.red(" \u2717 No AI API key found. Set one of the following:\n"));
|
|
4440
|
+
console.log(
|
|
4441
|
+
chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
|
|
4442
|
+
);
|
|
4443
|
+
console.log(
|
|
4444
|
+
chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
|
|
4445
|
+
);
|
|
4446
|
+
console.log(
|
|
4447
|
+
chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
|
|
4448
|
+
);
|
|
4449
|
+
console.log();
|
|
4450
|
+
console.log(
|
|
4451
|
+
chalk3.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n")
|
|
4452
|
+
);
|
|
4453
|
+
process.exit(1);
|
|
4454
|
+
}
|
|
4455
|
+
let provider;
|
|
4456
|
+
if (availableProviders.length === 1) {
|
|
4457
|
+
provider = availableProviders[0].create();
|
|
4458
|
+
} else {
|
|
4459
|
+
console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
|
|
4460
|
+
availableProviders.forEach((p, i) => {
|
|
4461
|
+
const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
|
|
4462
|
+
console.log(` ${label}`);
|
|
4463
|
+
});
|
|
4464
|
+
console.log();
|
|
4465
|
+
const answer = await askQuestion2(
|
|
4466
|
+
chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
|
|
4467
|
+
);
|
|
4468
|
+
const index = parseInt(answer, 10) - 1;
|
|
4469
|
+
if (isNaN(index) || index < 0 || index >= availableProviders.length) {
|
|
4470
|
+
const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
|
|
4471
|
+
provider = recommended.create();
|
|
4472
|
+
console.log(chalk3.gray(` Using ${recommended.name} (default)
|
|
4473
|
+
`));
|
|
4474
|
+
} else {
|
|
4475
|
+
provider = availableProviders[index].create();
|
|
4476
|
+
}
|
|
4477
|
+
}
|
|
4478
|
+
console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
|
|
4479
|
+
console.log();
|
|
6637
4480
|
const techSpinner = ora2({
|
|
6638
4481
|
text: "Detecting tech stack...",
|
|
6639
4482
|
prefixText: " "
|
|
@@ -6642,7 +4485,7 @@ async function reviewCommand(options) {
|
|
|
6642
4485
|
if (techStack.stack === "unknown") {
|
|
6643
4486
|
techSpinner.fail(
|
|
6644
4487
|
chalk3.red(
|
|
6645
|
-
"No supported project found. Supported: Swift,
|
|
4488
|
+
"No supported project found. Supported: Swift, Flutter, React Native, Kotlin, Capacitor, .NET MAUI"
|
|
6646
4489
|
)
|
|
6647
4490
|
);
|
|
6648
4491
|
process.exit(1);
|
|
@@ -6660,199 +4503,59 @@ async function reviewCommand(options) {
|
|
|
6660
4503
|
configSpinner.succeed(
|
|
6661
4504
|
`Config: ${chalk3.white(appName)} ${bundleId ? chalk3.gray(`(${bundleId})`) : ""}`
|
|
6662
4505
|
);
|
|
6663
|
-
const
|
|
6664
|
-
text: "
|
|
4506
|
+
const sdkSpinner = ora2({
|
|
4507
|
+
text: "Scanning dependencies...",
|
|
6665
4508
|
prefixText: " "
|
|
6666
4509
|
}).start();
|
|
6667
|
-
const
|
|
6668
|
-
|
|
6669
|
-
|
|
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})`)}`
|
|
4510
|
+
const sdkScan = scanSDKs(rootDir, techStack.stack);
|
|
4511
|
+
sdkSpinner.succeed(
|
|
4512
|
+
`Dependencies: ${chalk3.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
|
|
6680
4513
|
);
|
|
6681
4514
|
const sourceSpinner = ora2({
|
|
6682
4515
|
text: "Reading source code...",
|
|
6683
4516
|
prefixText: " "
|
|
6684
4517
|
}).start();
|
|
6685
4518
|
const maxChars = deep ? 4e5 : 15e4;
|
|
6686
|
-
const
|
|
6687
|
-
const sourceCode = joinSourceFiles(sourceFiles);
|
|
4519
|
+
const sourceCode = readSourceCode(rootDir, techStack.stack, maxChars);
|
|
6688
4520
|
const projectTree = generateProjectTree(rootDir);
|
|
6689
4521
|
const sourceLines = sourceCode.split("\n").length;
|
|
6690
4522
|
sourceSpinner.succeed(
|
|
6691
|
-
`Source code: ${chalk3.bold(String(sourceLines))} lines
|
|
4523
|
+
`Source code: ${chalk3.bold(String(sourceLines))} lines analyzed`
|
|
6692
4524
|
);
|
|
6693
|
-
|
|
6694
|
-
|
|
4525
|
+
console.log();
|
|
4526
|
+
const reviewSpinner = ora2({
|
|
4527
|
+
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)...`,
|
|
6695
4528
|
prefixText: " "
|
|
6696
4529
|
}).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
|
-
}
|
|
6763
4530
|
let report;
|
|
6764
|
-
|
|
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 {
|
|
4531
|
+
try {
|
|
6785
4532
|
const enrichedSource = buildEnrichedContext(
|
|
6786
4533
|
sourceCode,
|
|
6787
4534
|
projectTree,
|
|
6788
|
-
|
|
4535
|
+
sdkScan,
|
|
4536
|
+
config
|
|
6789
4537
|
);
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
|
|
6794
|
-
|
|
6795
|
-
|
|
6796
|
-
|
|
6797
|
-
|
|
6798
|
-
|
|
6799
|
-
|
|
6800
|
-
|
|
6801
|
-
|
|
6802
|
-
|
|
6803
|
-
|
|
6804
|
-
|
|
6805
|
-
|
|
6806
|
-
|
|
6807
|
-
|
|
6808
|
-
|
|
6809
|
-
|
|
6810
|
-
|
|
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
|
-
}
|
|
4538
|
+
report = await runCodeReview(
|
|
4539
|
+
provider,
|
|
4540
|
+
techStack.label,
|
|
4541
|
+
appName,
|
|
4542
|
+
bundleId,
|
|
4543
|
+
enrichedSource,
|
|
4544
|
+
sourceLines,
|
|
4545
|
+
deep
|
|
4546
|
+
);
|
|
4547
|
+
reviewSpinner.succeed(
|
|
4548
|
+
chalk3.green(
|
|
4549
|
+
`Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
|
|
4550
|
+
)
|
|
4551
|
+
);
|
|
4552
|
+
} catch (error) {
|
|
4553
|
+
reviewSpinner.fail(
|
|
4554
|
+
chalk3.red(
|
|
4555
|
+
`Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
4556
|
+
)
|
|
4557
|
+
);
|
|
4558
|
+
process.exit(1);
|
|
6856
4559
|
}
|
|
6857
4560
|
switch (format) {
|
|
6858
4561
|
case "json":
|
|
@@ -6917,16 +4620,14 @@ async function reviewCommand(options) {
|
|
|
6917
4620
|
}
|
|
6918
4621
|
}
|
|
6919
4622
|
}
|
|
6920
|
-
function buildEnrichedContext(sourceCode, projectTree,
|
|
4623
|
+
function buildEnrichedContext(sourceCode, projectTree, sdkScan, config) {
|
|
6921
4624
|
const sections = [];
|
|
6922
4625
|
sections.push(`## Project Structure
|
|
6923
4626
|
\`\`\`
|
|
6924
4627
|
${projectTree}
|
|
6925
4628
|
\`\`\``);
|
|
6926
|
-
if (
|
|
6927
|
-
const sdkList =
|
|
6928
|
-
(sdk) => `- ${sdk.name} (${sdk.category})${sdk.data_collection.length > 0 ? `, collects: ${sdk.data_collection.join(", ")}` : ""}`
|
|
6929
|
-
).join("\n");
|
|
4629
|
+
if (sdkScan.detected_sdks.length > 0) {
|
|
4630
|
+
const sdkList = sdkScan.detected_sdks.map((sdk) => `- ${sdk.name} (${sdk.category})${sdk.collects.length > 0 ? ` \u2014 collects: ${sdk.collects.join(", ")}` : ""}`).join("\n");
|
|
6930
4631
|
sections.push(`## Detected SDKs
|
|
6931
4632
|
${sdkList}`);
|
|
6932
4633
|
}
|
|
@@ -6938,7 +4639,7 @@ ${sourceCode}`);
|
|
|
6938
4639
|
|
|
6939
4640
|
// src/index.ts
|
|
6940
4641
|
var program = new Command();
|
|
6941
|
-
program.name("forvibe").description("Forvibe CLI
|
|
4642
|
+
program.name("forvibe").description("Forvibe CLI \u2014 AI-powered App Store automation").version("0.1.0");
|
|
6942
4643
|
program.command("analyze", { isDefault: true }).description(
|
|
6943
4644
|
"Analyze your project and send the report to Forvibe for automated App Store setup"
|
|
6944
4645
|
).option("-d, --dir <path>", "Project directory to analyze", process.cwd()).option(
|
|
@@ -6948,13 +4649,9 @@ program.command("analyze", { isDefault: true }).description(
|
|
|
6948
4649
|
).action(analyzeCommand);
|
|
6949
4650
|
program.command("review").description(
|
|
6950
4651
|
"Simulate Apple's App Store review process on your project"
|
|
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("--
|
|
4652
|
+
).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(
|
|
6952
4653
|
"--api-url <url>",
|
|
6953
4654
|
"Forvibe API URL (for development)",
|
|
6954
4655
|
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
|
|
6959
4656
|
).action(reviewCommand);
|
|
6960
4657
|
program.parse();
|