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