@forvibe/cli 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1364 -105
- package/dist/{report-generator-QV2BWI2J.js → report-generator-7E72G6KB.js} +1 -0
- package/dist/review/rag-data/business.json +282 -0
- package/dist/review/rag-data/common-pitfalls.json +212 -0
- package/dist/review/rag-data/design.json +212 -0
- package/dist/review/rag-data/legal.json +212 -0
- package/dist/review/rag-data/performance.json +282 -0
- package/dist/review/rag-data/safety.json +212 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -933,8 +933,8 @@ function getDependencies(rootDir, techStack) {
|
|
|
933
933
|
join4(rootDir, "build.gradle"),
|
|
934
934
|
join4(rootDir, "build.gradle.kts")
|
|
935
935
|
];
|
|
936
|
-
for (const
|
|
937
|
-
const content = readFileSafe(
|
|
936
|
+
for (const path2 of gradlePaths) {
|
|
937
|
+
const content = readFileSafe(path2);
|
|
938
938
|
if (content) {
|
|
939
939
|
const implMatches = content.matchAll(
|
|
940
940
|
/implementation\s*\(?['"]([^'"]+)['"]\)?/g
|
|
@@ -985,11 +985,13 @@ function extractBranding(rootDir, techStack) {
|
|
|
985
985
|
primary_color: null,
|
|
986
986
|
secondary_color: null,
|
|
987
987
|
app_icon_base64: null,
|
|
988
|
-
app_icon_path: null
|
|
988
|
+
app_icon_path: null,
|
|
989
|
+
brand_colors: []
|
|
989
990
|
};
|
|
990
991
|
const colors = extractColors(rootDir, techStack);
|
|
991
992
|
result.primary_color = colors.primary;
|
|
992
993
|
result.secondary_color = colors.secondary;
|
|
994
|
+
result.brand_colors = extractBrandColors(colors.allColors);
|
|
993
995
|
const icon = findAppIcon(rootDir, techStack);
|
|
994
996
|
result.app_icon_base64 = icon.base64;
|
|
995
997
|
result.app_icon_path = icon.path;
|
|
@@ -1008,9 +1010,29 @@ function extractColors(rootDir, techStack) {
|
|
|
1008
1010
|
case "java":
|
|
1009
1011
|
return extractAndroidColors(rootDir);
|
|
1010
1012
|
default:
|
|
1011
|
-
return { primary: null, secondary: null };
|
|
1013
|
+
return { primary: null, secondary: null, allColors: [] };
|
|
1012
1014
|
}
|
|
1013
1015
|
}
|
|
1016
|
+
function isIrrelevantColor(hex) {
|
|
1017
|
+
const r = parseInt(hex.slice(1, 3), 16);
|
|
1018
|
+
const g = parseInt(hex.slice(3, 5), 16);
|
|
1019
|
+
const b = parseInt(hex.slice(5, 7), 16);
|
|
1020
|
+
const brightness = (r * 299 + g * 587 + b * 114) / 1e3;
|
|
1021
|
+
if (brightness > 242 || brightness < 13) return true;
|
|
1022
|
+
const maxDiff = Math.max(Math.abs(r - g), Math.abs(g - b), Math.abs(r - b));
|
|
1023
|
+
if (maxDiff < 15) return true;
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
function extractBrandColors(allColors) {
|
|
1027
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1028
|
+
for (const raw of allColors) {
|
|
1029
|
+
const hex = raw.toLowerCase().slice(0, 7);
|
|
1030
|
+
if (hex.length !== 7 || !hex.startsWith("#")) continue;
|
|
1031
|
+
freq.set(hex, (freq.get(hex) || 0) + 1);
|
|
1032
|
+
}
|
|
1033
|
+
const filtered = [...freq.entries()].filter(([hex]) => !isIrrelevantColor(hex)).sort((a, b) => b[1] - a[1]);
|
|
1034
|
+
return filtered.slice(0, 3).map(([hex]) => hex);
|
|
1035
|
+
}
|
|
1014
1036
|
function extractFlutterColors(rootDir) {
|
|
1015
1037
|
const dartFiles = findFiles(rootDir, [".dart"], 6);
|
|
1016
1038
|
const hexColors = [];
|
|
@@ -1038,14 +1060,16 @@ function extractFlutterColors(rootDir) {
|
|
|
1038
1060
|
if (primaryMatch[2]) {
|
|
1039
1061
|
return {
|
|
1040
1062
|
primary: `#${primaryMatch[2].substring(2)}`,
|
|
1041
|
-
secondary: hexColors.length > 1 ? hexColors[1] : null
|
|
1063
|
+
secondary: hexColors.length > 1 ? hexColors[1] : null,
|
|
1064
|
+
allColors: hexColors
|
|
1042
1065
|
};
|
|
1043
1066
|
}
|
|
1044
1067
|
}
|
|
1045
1068
|
}
|
|
1046
1069
|
return {
|
|
1047
1070
|
primary: hexColors.length > 0 ? hexColors[0] : null,
|
|
1048
|
-
secondary: hexColors.length > 1 ? hexColors[1] : null
|
|
1071
|
+
secondary: hexColors.length > 1 ? hexColors[1] : null,
|
|
1072
|
+
allColors: hexColors
|
|
1049
1073
|
};
|
|
1050
1074
|
}
|
|
1051
1075
|
function extractJSColors(rootDir) {
|
|
@@ -1082,14 +1106,16 @@ function extractJSColors(rootDir) {
|
|
|
1082
1106
|
);
|
|
1083
1107
|
return {
|
|
1084
1108
|
primary: `#${primaryMatch[1]}`,
|
|
1085
|
-
secondary: secondaryMatch ? `#${secondaryMatch[1]}` : null
|
|
1109
|
+
secondary: secondaryMatch ? `#${secondaryMatch[1]}` : null,
|
|
1110
|
+
allColors: hexColors
|
|
1086
1111
|
};
|
|
1087
1112
|
}
|
|
1088
1113
|
}
|
|
1089
1114
|
}
|
|
1090
1115
|
return {
|
|
1091
1116
|
primary: hexColors.length > 0 ? hexColors[0] : null,
|
|
1092
|
-
secondary: hexColors.length > 1 ? hexColors[1] : null
|
|
1117
|
+
secondary: hexColors.length > 1 ? hexColors[1] : null,
|
|
1118
|
+
allColors: hexColors
|
|
1093
1119
|
};
|
|
1094
1120
|
}
|
|
1095
1121
|
function extractSwiftColors(rootDir) {
|
|
@@ -1147,7 +1173,8 @@ function extractSwiftColors(rootDir) {
|
|
|
1147
1173
|
}
|
|
1148
1174
|
return {
|
|
1149
1175
|
primary: hexColors.length > 0 ? hexColors[0] : null,
|
|
1150
|
-
secondary: hexColors.length > 1 ? hexColors[1] : null
|
|
1176
|
+
secondary: hexColors.length > 1 ? hexColors[1] : null,
|
|
1177
|
+
allColors: hexColors
|
|
1151
1178
|
};
|
|
1152
1179
|
}
|
|
1153
1180
|
function extractAndroidColors(rootDir) {
|
|
@@ -1196,7 +1223,8 @@ function extractAndroidColors(rootDir) {
|
|
|
1196
1223
|
}
|
|
1197
1224
|
return {
|
|
1198
1225
|
primary: hexColors.length > 0 ? hexColors[0] : null,
|
|
1199
|
-
secondary: hexColors.length > 1 ? hexColors[1] : null
|
|
1226
|
+
secondary: hexColors.length > 1 ? hexColors[1] : null,
|
|
1227
|
+
allColors: hexColors
|
|
1200
1228
|
};
|
|
1201
1229
|
}
|
|
1202
1230
|
function findAssetsCatalogs(rootDir) {
|
|
@@ -1369,9 +1397,290 @@ function findIconsInAppIconSet(searchDir) {
|
|
|
1369
1397
|
return icons;
|
|
1370
1398
|
}
|
|
1371
1399
|
|
|
1400
|
+
// src/analyzers/font-detector.ts
|
|
1401
|
+
import { existsSync as existsSync4, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
|
|
1402
|
+
import { join as join6, extname as extname2, basename } from "path";
|
|
1403
|
+
import yaml from "yaml";
|
|
1404
|
+
import plist2 from "plist";
|
|
1405
|
+
var SYSTEM_FONTS = /* @__PURE__ */ new Set([
|
|
1406
|
+
"system",
|
|
1407
|
+
"sans-serif",
|
|
1408
|
+
"serif",
|
|
1409
|
+
"monospace",
|
|
1410
|
+
"cursive",
|
|
1411
|
+
"fantasy",
|
|
1412
|
+
"sans-serif-medium",
|
|
1413
|
+
"sans-serif-light",
|
|
1414
|
+
"sans-serif-thin",
|
|
1415
|
+
"sans-serif-black",
|
|
1416
|
+
"sans-serif-condensed",
|
|
1417
|
+
"sans-serif-smallcaps",
|
|
1418
|
+
"sf pro",
|
|
1419
|
+
"sf pro text",
|
|
1420
|
+
"sf pro display",
|
|
1421
|
+
"sf pro rounded",
|
|
1422
|
+
"sf compact",
|
|
1423
|
+
"sf mono",
|
|
1424
|
+
"new york",
|
|
1425
|
+
"helvetica",
|
|
1426
|
+
"helvetica neue",
|
|
1427
|
+
"arial",
|
|
1428
|
+
"times new roman",
|
|
1429
|
+
"courier",
|
|
1430
|
+
"roboto"
|
|
1431
|
+
// Android system font — usually not a brand choice
|
|
1432
|
+
]);
|
|
1433
|
+
var WEIGHT_SUFFIXES = /[-_ ]?(Regular|Bold|Italic|Light|Medium|SemiBold|ExtraBold|Thin|Black|Heavy|Book|Condensed|Expanded|ExtraLight|UltraLight|UltraBold|DemiBold|Oblique|Roman|Normal|Variable|Display|Text|Mono)\b/gi;
|
|
1434
|
+
function cleanFontName(raw) {
|
|
1435
|
+
let name = raw.replace(/\.(ttf|otf|woff2?|eot)$/i, "");
|
|
1436
|
+
name = name.replace(WEIGHT_SUFFIXES, "");
|
|
1437
|
+
name = name.replace(/[-_]/g, " ").trim();
|
|
1438
|
+
name = name.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
1439
|
+
name = name.replace(/\s+/g, " ").trim();
|
|
1440
|
+
return name;
|
|
1441
|
+
}
|
|
1442
|
+
function camelToTitleCase(camel) {
|
|
1443
|
+
return camel.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (s) => s.toUpperCase());
|
|
1444
|
+
}
|
|
1445
|
+
function fontNameFromFile(fileName) {
|
|
1446
|
+
return cleanFontName(basename(fileName));
|
|
1447
|
+
}
|
|
1448
|
+
function scanForFontFiles(dir, maxDepth = 3) {
|
|
1449
|
+
const fonts = [];
|
|
1450
|
+
function walk(d, depth) {
|
|
1451
|
+
if (depth > maxDepth) return;
|
|
1452
|
+
try {
|
|
1453
|
+
const entries = readdirSync5(d);
|
|
1454
|
+
for (const entry of entries) {
|
|
1455
|
+
if (entry.startsWith(".")) continue;
|
|
1456
|
+
const fullPath = join6(d, entry);
|
|
1457
|
+
try {
|
|
1458
|
+
const stat = statSync3(fullPath);
|
|
1459
|
+
if (stat.isDirectory()) {
|
|
1460
|
+
walk(fullPath, depth + 1);
|
|
1461
|
+
} else {
|
|
1462
|
+
const ext = extname2(entry).toLowerCase();
|
|
1463
|
+
if (ext === ".ttf" || ext === ".otf") {
|
|
1464
|
+
fonts.push(entry);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
} catch {
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
} catch {
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
if (existsSync4(dir)) walk(dir, 0);
|
|
1474
|
+
return fonts;
|
|
1475
|
+
}
|
|
1476
|
+
function detectFlutterFonts(rootDir) {
|
|
1477
|
+
const fonts = [];
|
|
1478
|
+
const pubspecPath = join6(rootDir, "pubspec.yaml");
|
|
1479
|
+
const pubspecContent = readFileSafe(pubspecPath);
|
|
1480
|
+
if (pubspecContent) {
|
|
1481
|
+
try {
|
|
1482
|
+
const doc = yaml.parse(pubspecContent);
|
|
1483
|
+
const flutterFonts = doc?.flutter?.fonts;
|
|
1484
|
+
if (Array.isArray(flutterFonts)) {
|
|
1485
|
+
for (const entry of flutterFonts) {
|
|
1486
|
+
if (entry.family && typeof entry.family === "string") {
|
|
1487
|
+
fonts.push(entry.family);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
} catch {
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
const dartFiles = findFiles(rootDir, [".dart"], 6);
|
|
1495
|
+
for (const file of dartFiles.slice(0, 80)) {
|
|
1496
|
+
const content = readFileSafe(file);
|
|
1497
|
+
if (!content) continue;
|
|
1498
|
+
const fontFamilyMatches = content.matchAll(/fontFamily\s*:\s*['"]([^'"]+)['"]/g);
|
|
1499
|
+
for (const match of fontFamilyMatches) {
|
|
1500
|
+
fonts.push(match[1]);
|
|
1501
|
+
}
|
|
1502
|
+
const googleFontsMatches = content.matchAll(/GoogleFonts\.(\w+)\s*\(/g);
|
|
1503
|
+
for (const match of googleFontsMatches) {
|
|
1504
|
+
if (match[1] !== "getFont" && match[1] !== "getTextTheme") {
|
|
1505
|
+
fonts.push(camelToTitleCase(match[1]));
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const getFontMatches = content.matchAll(/GoogleFonts\.getFont\s*\(\s*['"]([^'"]+)['"]/g);
|
|
1509
|
+
for (const match of getFontMatches) {
|
|
1510
|
+
fonts.push(match[1]);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
return fonts;
|
|
1514
|
+
}
|
|
1515
|
+
function detectJSFonts(rootDir) {
|
|
1516
|
+
const fonts = [];
|
|
1517
|
+
const jsFiles = findFiles(rootDir, [".ts", ".tsx", ".js", ".jsx"], 5);
|
|
1518
|
+
for (const file of jsFiles.slice(0, 100)) {
|
|
1519
|
+
const content = readFileSafe(file);
|
|
1520
|
+
if (!content) continue;
|
|
1521
|
+
const matches = content.matchAll(/fontFamily\s*:\s*['"]([^'"]+)['"]/g);
|
|
1522
|
+
for (const match of matches) {
|
|
1523
|
+
fonts.push(match[1]);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
const fontDirs = [
|
|
1527
|
+
join6(rootDir, "assets/fonts"),
|
|
1528
|
+
join6(rootDir, "src/assets/fonts"),
|
|
1529
|
+
join6(rootDir, "app/assets/fonts")
|
|
1530
|
+
];
|
|
1531
|
+
for (const dir of fontDirs) {
|
|
1532
|
+
const fontFiles = scanForFontFiles(dir, 1);
|
|
1533
|
+
for (const f of fontFiles) {
|
|
1534
|
+
fonts.push(fontNameFromFile(f));
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return fonts;
|
|
1538
|
+
}
|
|
1539
|
+
function detectSwiftFonts(rootDir) {
|
|
1540
|
+
const fonts = [];
|
|
1541
|
+
const swiftFiles = findFiles(rootDir, [".swift"], 6);
|
|
1542
|
+
for (const file of swiftFiles.slice(0, 80)) {
|
|
1543
|
+
const content = readFileSafe(file);
|
|
1544
|
+
if (!content) continue;
|
|
1545
|
+
const uiFontMatches = content.matchAll(/UIFont\s*\(\s*name:\s*"([^"]+)"/g);
|
|
1546
|
+
for (const match of uiFontMatches) {
|
|
1547
|
+
fonts.push(match[1]);
|
|
1548
|
+
}
|
|
1549
|
+
const customFontMatches = content.matchAll(/\.?custom\s*\(\s*"([^"]+)"/g);
|
|
1550
|
+
for (const match of customFontMatches) {
|
|
1551
|
+
fonts.push(match[1]);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const plistPaths = [
|
|
1555
|
+
join6(rootDir, "Info.plist"),
|
|
1556
|
+
...findPlistFiles(rootDir)
|
|
1557
|
+
];
|
|
1558
|
+
for (const plistPath of plistPaths) {
|
|
1559
|
+
const content = readFileSafe(plistPath);
|
|
1560
|
+
if (!content) continue;
|
|
1561
|
+
try {
|
|
1562
|
+
const data = plist2.parse(content);
|
|
1563
|
+
const appFonts = data.UIAppFonts;
|
|
1564
|
+
if (Array.isArray(appFonts)) {
|
|
1565
|
+
for (const fontFile of appFonts) {
|
|
1566
|
+
if (typeof fontFile === "string") {
|
|
1567
|
+
fonts.push(fontNameFromFile(fontFile));
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
} catch {
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
const fontDirs = [
|
|
1575
|
+
join6(rootDir, "Fonts"),
|
|
1576
|
+
join6(rootDir, "Resources/Fonts")
|
|
1577
|
+
];
|
|
1578
|
+
for (const dir of fontDirs) {
|
|
1579
|
+
const fontFiles = scanForFontFiles(dir, 2);
|
|
1580
|
+
for (const f of fontFiles) {
|
|
1581
|
+
fonts.push(fontNameFromFile(f));
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
return fonts;
|
|
1585
|
+
}
|
|
1586
|
+
function findPlistFiles(rootDir) {
|
|
1587
|
+
const results = [];
|
|
1588
|
+
function search(dir, depth) {
|
|
1589
|
+
if (depth > 4) return;
|
|
1590
|
+
try {
|
|
1591
|
+
const entries = readdirSync5(dir);
|
|
1592
|
+
for (const entry of entries) {
|
|
1593
|
+
if (entry.startsWith(".") || entry === "Pods" || entry === "DerivedData" || entry === "build") continue;
|
|
1594
|
+
const fullPath = join6(dir, entry);
|
|
1595
|
+
if (entry === "Info.plist") {
|
|
1596
|
+
results.push(fullPath);
|
|
1597
|
+
continue;
|
|
1598
|
+
}
|
|
1599
|
+
try {
|
|
1600
|
+
if (statSync3(fullPath).isDirectory()) {
|
|
1601
|
+
search(fullPath, depth + 1);
|
|
1602
|
+
}
|
|
1603
|
+
} catch {
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
} catch {
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
search(rootDir, 0);
|
|
1610
|
+
return results;
|
|
1611
|
+
}
|
|
1612
|
+
function detectAndroidFonts(rootDir) {
|
|
1613
|
+
const fonts = [];
|
|
1614
|
+
const resFontDirs = [
|
|
1615
|
+
join6(rootDir, "app/src/main/res/font"),
|
|
1616
|
+
join6(rootDir, "src/main/res/font")
|
|
1617
|
+
];
|
|
1618
|
+
for (const dir of resFontDirs) {
|
|
1619
|
+
const fontFiles = scanForFontFiles(dir, 1);
|
|
1620
|
+
for (const f of fontFiles) {
|
|
1621
|
+
fonts.push(fontNameFromFile(f));
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const xmlFiles = findFiles(rootDir, [".xml"], 5);
|
|
1625
|
+
for (const file of xmlFiles.slice(0, 50)) {
|
|
1626
|
+
const content = readFileSafe(file);
|
|
1627
|
+
if (!content) continue;
|
|
1628
|
+
const matches = content.matchAll(/android:fontFamily\s*=\s*"(?:@font\/)?([^"]+)"/g);
|
|
1629
|
+
for (const match of matches) {
|
|
1630
|
+
const name = match[1];
|
|
1631
|
+
fonts.push(name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
const ktFiles = findFiles(rootDir, [".kt"], 6);
|
|
1635
|
+
for (const file of ktFiles.slice(0, 80)) {
|
|
1636
|
+
const content = readFileSafe(file);
|
|
1637
|
+
if (!content) continue;
|
|
1638
|
+
const fontResMatches = content.matchAll(/Font\s*\(\s*R\.font\.(\w+)/g);
|
|
1639
|
+
for (const match of fontResMatches) {
|
|
1640
|
+
fonts.push(match[1].replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()));
|
|
1641
|
+
}
|
|
1642
|
+
const fontFamilyMatches = content.matchAll(/fontFamily\s*=\s*FontFamily\s*\(/g);
|
|
1643
|
+
void fontFamilyMatches;
|
|
1644
|
+
}
|
|
1645
|
+
return fonts;
|
|
1646
|
+
}
|
|
1647
|
+
function detectFonts(rootDir, techStack) {
|
|
1648
|
+
let rawFonts;
|
|
1649
|
+
switch (techStack) {
|
|
1650
|
+
case "flutter":
|
|
1651
|
+
rawFonts = detectFlutterFonts(rootDir);
|
|
1652
|
+
break;
|
|
1653
|
+
case "react-native":
|
|
1654
|
+
case "capacitor":
|
|
1655
|
+
rawFonts = detectJSFonts(rootDir);
|
|
1656
|
+
break;
|
|
1657
|
+
case "swift":
|
|
1658
|
+
rawFonts = detectSwiftFonts(rootDir);
|
|
1659
|
+
break;
|
|
1660
|
+
case "kotlin":
|
|
1661
|
+
case "java":
|
|
1662
|
+
rawFonts = detectAndroidFonts(rootDir);
|
|
1663
|
+
break;
|
|
1664
|
+
default:
|
|
1665
|
+
rawFonts = [];
|
|
1666
|
+
}
|
|
1667
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1668
|
+
const result = [];
|
|
1669
|
+
for (const raw of rawFonts) {
|
|
1670
|
+
const cleaned = cleanFontName(raw);
|
|
1671
|
+
if (!cleaned || cleaned.length < 2) continue;
|
|
1672
|
+
const key = cleaned.toLowerCase();
|
|
1673
|
+
if (seen.has(key)) continue;
|
|
1674
|
+
if (SYSTEM_FONTS.has(key)) continue;
|
|
1675
|
+
seen.add(key);
|
|
1676
|
+
result.push(cleaned);
|
|
1677
|
+
}
|
|
1678
|
+
return result.sort().slice(0, 5);
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1372
1681
|
// src/analyzers/source-reader.ts
|
|
1373
|
-
import { readdirSync as
|
|
1374
|
-
import { join as
|
|
1682
|
+
import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
|
|
1683
|
+
import { join as join7, relative as relative2 } from "path";
|
|
1375
1684
|
var README_NAMES = [
|
|
1376
1685
|
"README.md",
|
|
1377
1686
|
"readme.md",
|
|
@@ -1410,7 +1719,7 @@ var TREE_IGNORE = /* @__PURE__ */ new Set([
|
|
|
1410
1719
|
]);
|
|
1411
1720
|
function readReadme(rootDir) {
|
|
1412
1721
|
for (const name of README_NAMES) {
|
|
1413
|
-
const content = readFileSafe(
|
|
1722
|
+
const content = readFileSafe(join7(rootDir, name));
|
|
1414
1723
|
if (content && content.trim().length > 0) {
|
|
1415
1724
|
return content.substring(0, 1e4);
|
|
1416
1725
|
}
|
|
@@ -1422,19 +1731,19 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
1422
1731
|
const priorityPatterns = getPriorityPatternsForStack(techStack);
|
|
1423
1732
|
const allFiles = scanDirectory(rootDir, {
|
|
1424
1733
|
extensions,
|
|
1425
|
-
maxDepth:
|
|
1426
|
-
maxFiles:
|
|
1734
|
+
maxDepth: 8,
|
|
1735
|
+
maxFiles: 500
|
|
1427
1736
|
});
|
|
1428
1737
|
const sorted = sortByPriority(allFiles, priorityPatterns);
|
|
1429
1738
|
const parts = [];
|
|
1430
1739
|
let totalChars = 0;
|
|
1431
1740
|
for (const file of sorted) {
|
|
1432
1741
|
if (totalChars >= maxTotalChars) break;
|
|
1433
|
-
const content = readFileSafe(
|
|
1742
|
+
const content = readFileSafe(join7(rootDir, file));
|
|
1434
1743
|
if (!content || content.trim().length < 20) continue;
|
|
1435
1744
|
if (isTestFile(file)) continue;
|
|
1436
1745
|
if (isGeneratedFile(file, content)) continue;
|
|
1437
|
-
const truncated = content.substring(0,
|
|
1746
|
+
const truncated = content.substring(0, 15e3);
|
|
1438
1747
|
parts.push(`--- ${file} ---
|
|
1439
1748
|
${truncated}`);
|
|
1440
1749
|
totalChars += truncated.length;
|
|
@@ -1525,9 +1834,9 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1525
1834
|
if (depth > maxDepth || entryCount >= maxEntries) return;
|
|
1526
1835
|
let entries;
|
|
1527
1836
|
try {
|
|
1528
|
-
entries =
|
|
1529
|
-
const aIsDir = isDir(
|
|
1530
|
-
const bIsDir = isDir(
|
|
1837
|
+
entries = readdirSync6(dir).sort((a, b) => {
|
|
1838
|
+
const aIsDir = isDir(join7(dir, a));
|
|
1839
|
+
const bIsDir = isDir(join7(dir, b));
|
|
1531
1840
|
if (aIsDir && !bIsDir) return -1;
|
|
1532
1841
|
if (!aIsDir && bIsDir) return 1;
|
|
1533
1842
|
return a.localeCompare(b);
|
|
@@ -1546,7 +1855,7 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1546
1855
|
return;
|
|
1547
1856
|
}
|
|
1548
1857
|
const entry = entries[i];
|
|
1549
|
-
const fullPath =
|
|
1858
|
+
const fullPath = join7(dir, entry);
|
|
1550
1859
|
const isLast = i === entries.length - 1;
|
|
1551
1860
|
const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
|
|
1552
1861
|
const childPrefix = isLast ? " " : "\u2502 ";
|
|
@@ -1560,22 +1869,22 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1560
1869
|
}
|
|
1561
1870
|
}
|
|
1562
1871
|
}
|
|
1563
|
-
function isDir(
|
|
1872
|
+
function isDir(path2) {
|
|
1564
1873
|
try {
|
|
1565
|
-
return
|
|
1874
|
+
return statSync4(path2).isDirectory();
|
|
1566
1875
|
} catch {
|
|
1567
1876
|
return false;
|
|
1568
1877
|
}
|
|
1569
1878
|
}
|
|
1570
|
-
const rootName = relative2(
|
|
1879
|
+
const rootName = relative2(join7(rootDir, ".."), rootDir) || "project";
|
|
1571
1880
|
lines.push(`${rootName}/`);
|
|
1572
1881
|
walk(rootDir, "", 0);
|
|
1573
1882
|
return lines.join("\n");
|
|
1574
1883
|
}
|
|
1575
1884
|
|
|
1576
1885
|
// src/analyzers/asset-scanner.ts
|
|
1577
|
-
import { readFileSync as
|
|
1578
|
-
import { join as
|
|
1886
|
+
import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as readdirSync7, statSync as statSync5 } from "fs";
|
|
1887
|
+
import { join as join8, extname as extname3, relative as relative3, basename as basename2 } from "path";
|
|
1579
1888
|
var MAX_ASSET_SIZE = 1 * 1024 * 1024;
|
|
1580
1889
|
var MAX_TOTAL_BASE64_SIZE = 3 * 1024 * 1024;
|
|
1581
1890
|
var MAX_TOTAL_ASSETS = 10;
|
|
@@ -1584,25 +1893,25 @@ var MIN_ASSET_SIZE = 500;
|
|
|
1584
1893
|
var MIN_SCREENSHOT_SIZE = 1e4;
|
|
1585
1894
|
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
|
|
1586
1895
|
function getMimeType(filePath) {
|
|
1587
|
-
const ext =
|
|
1896
|
+
const ext = extname3(filePath).toLowerCase();
|
|
1588
1897
|
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
1589
1898
|
if (ext === ".webp") return "image/webp";
|
|
1590
1899
|
return "image/png";
|
|
1591
1900
|
}
|
|
1592
1901
|
function isImageFile(filePath) {
|
|
1593
|
-
return IMAGE_EXTENSIONS.has(
|
|
1902
|
+
return IMAGE_EXTENSIONS.has(extname3(filePath).toLowerCase());
|
|
1594
1903
|
}
|
|
1595
1904
|
function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
|
|
1596
1905
|
try {
|
|
1597
|
-
if (!
|
|
1598
|
-
const stat =
|
|
1906
|
+
if (!existsSync5(filePath)) return null;
|
|
1907
|
+
const stat = statSync5(filePath);
|
|
1599
1908
|
if (!stat.isFile()) return null;
|
|
1600
1909
|
if (stat.size < minSize) return null;
|
|
1601
1910
|
if (stat.size > MAX_ASSET_SIZE) return null;
|
|
1602
|
-
const buffer =
|
|
1911
|
+
const buffer = readFileSync4(filePath);
|
|
1603
1912
|
return {
|
|
1604
1913
|
asset_type: assetType,
|
|
1605
|
-
file_name:
|
|
1914
|
+
file_name: basename2(filePath),
|
|
1606
1915
|
mime_type: getMimeType(filePath),
|
|
1607
1916
|
base64_data: buffer.toString("base64"),
|
|
1608
1917
|
width: null,
|
|
@@ -1615,13 +1924,13 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
|
|
|
1615
1924
|
}
|
|
1616
1925
|
function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, maxDepth = 3, currentDepth = 0, minSize = MIN_ASSET_SIZE) {
|
|
1617
1926
|
if (currentDepth > maxDepth) return;
|
|
1618
|
-
if (!
|
|
1927
|
+
if (!existsSync5(dirPath)) return;
|
|
1619
1928
|
try {
|
|
1620
|
-
const entries =
|
|
1929
|
+
const entries = readdirSync7(dirPath);
|
|
1621
1930
|
for (const entry of entries) {
|
|
1622
1931
|
if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
|
|
1623
1932
|
if (assets.length >= MAX_TOTAL_ASSETS) return;
|
|
1624
|
-
const fullPath =
|
|
1933
|
+
const fullPath = join8(dirPath, entry);
|
|
1625
1934
|
if (isImageFile(entry)) {
|
|
1626
1935
|
const asset = tryReadImage(rootDir, fullPath, assetType, minSize);
|
|
1627
1936
|
if (asset) assets.push(asset);
|
|
@@ -1629,9 +1938,9 @@ function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, m
|
|
|
1629
1938
|
}
|
|
1630
1939
|
for (const entry of entries) {
|
|
1631
1940
|
if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
|
|
1632
|
-
const fullPath =
|
|
1941
|
+
const fullPath = join8(dirPath, entry);
|
|
1633
1942
|
try {
|
|
1634
|
-
if (
|
|
1943
|
+
if (statSync5(fullPath).isDirectory() && !entry.startsWith(".")) {
|
|
1635
1944
|
collectImagesRecursive(rootDir, fullPath, assetType, assets, maxCount, maxDepth, currentDepth + 1, minSize);
|
|
1636
1945
|
}
|
|
1637
1946
|
} catch {
|
|
@@ -1645,53 +1954,53 @@ function getScreenshotDirs(rootDir, techStack) {
|
|
|
1645
1954
|
const ss = (p) => ({ path: p, minSize: MIN_ASSET_SIZE });
|
|
1646
1955
|
const gen = (p) => ({ path: p, minSize: MIN_SCREENSHOT_SIZE });
|
|
1647
1956
|
dirs.push(
|
|
1648
|
-
ss(
|
|
1649
|
-
ss(
|
|
1650
|
-
ss(
|
|
1651
|
-
ss(
|
|
1957
|
+
ss(join8(rootDir, "screenshots")),
|
|
1958
|
+
ss(join8(rootDir, "Screenshots")),
|
|
1959
|
+
ss(join8(rootDir, "assets/screenshots")),
|
|
1960
|
+
ss(join8(rootDir, "assets/images/screenshots"))
|
|
1652
1961
|
);
|
|
1653
1962
|
dirs.push(
|
|
1654
|
-
ss(
|
|
1655
|
-
ss(
|
|
1656
|
-
ss(
|
|
1963
|
+
ss(join8(rootDir, "fastlane/screenshots")),
|
|
1964
|
+
ss(join8(rootDir, "fastlane/metadata/en-US/images/phoneScreenshots")),
|
|
1965
|
+
ss(join8(rootDir, "fastlane/metadata/en-US/images/tabletScreenshots"))
|
|
1657
1966
|
);
|
|
1658
1967
|
switch (techStack) {
|
|
1659
1968
|
case "flutter":
|
|
1660
1969
|
dirs.push(
|
|
1661
|
-
ss(
|
|
1662
|
-
ss(
|
|
1970
|
+
ss(join8(rootDir, "assets/screenshots")),
|
|
1971
|
+
ss(join8(rootDir, "metadata/screenshots")),
|
|
1663
1972
|
// General Flutter asset directories — use higher threshold
|
|
1664
|
-
gen(
|
|
1665
|
-
gen(
|
|
1666
|
-
gen(
|
|
1973
|
+
gen(join8(rootDir, "assets/images")),
|
|
1974
|
+
gen(join8(rootDir, "assets/img")),
|
|
1975
|
+
gen(join8(rootDir, "assets"))
|
|
1667
1976
|
);
|
|
1668
1977
|
break;
|
|
1669
1978
|
case "react-native":
|
|
1670
1979
|
case "capacitor":
|
|
1671
1980
|
dirs.push(
|
|
1672
|
-
ss(
|
|
1673
|
-
gen(
|
|
1674
|
-
gen(
|
|
1675
|
-
ss(
|
|
1676
|
-
gen(
|
|
1677
|
-
gen(
|
|
1981
|
+
ss(join8(rootDir, "src/assets/screenshots")),
|
|
1982
|
+
gen(join8(rootDir, "src/assets/images")),
|
|
1983
|
+
gen(join8(rootDir, "src/assets")),
|
|
1984
|
+
ss(join8(rootDir, "docs/screenshots")),
|
|
1985
|
+
gen(join8(rootDir, "assets/images")),
|
|
1986
|
+
gen(join8(rootDir, "assets"))
|
|
1678
1987
|
);
|
|
1679
1988
|
break;
|
|
1680
1989
|
case "swift":
|
|
1681
1990
|
dirs.push(
|
|
1682
|
-
ss(
|
|
1683
|
-
ss(
|
|
1684
|
-
gen(
|
|
1685
|
-
gen(
|
|
1991
|
+
ss(join8(rootDir, "fastlane/screenshots/en-US")),
|
|
1992
|
+
ss(join8(rootDir, "fastlane/metadata/en-US/images/phoneScreenshots")),
|
|
1993
|
+
gen(join8(rootDir, "marketing")),
|
|
1994
|
+
gen(join8(rootDir, "Marketing"))
|
|
1686
1995
|
);
|
|
1687
1996
|
break;
|
|
1688
1997
|
case "kotlin":
|
|
1689
1998
|
case "java":
|
|
1690
1999
|
dirs.push(
|
|
1691
|
-
ss(
|
|
1692
|
-
gen(
|
|
1693
|
-
gen(
|
|
1694
|
-
gen(
|
|
2000
|
+
ss(join8(rootDir, "fastlane/metadata/android/en-US/images/phoneScreenshots")),
|
|
2001
|
+
gen(join8(rootDir, "metadata/android/en-US/images")),
|
|
2002
|
+
gen(join8(rootDir, "marketing")),
|
|
2003
|
+
gen(join8(rootDir, "Marketing"))
|
|
1695
2004
|
);
|
|
1696
2005
|
break;
|
|
1697
2006
|
}
|
|
@@ -1702,39 +2011,39 @@ function getSplashPaths(rootDir, techStack) {
|
|
|
1702
2011
|
switch (techStack) {
|
|
1703
2012
|
case "flutter":
|
|
1704
2013
|
paths.push(
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2014
|
+
join8(rootDir, "assets/splash.png"),
|
|
2015
|
+
join8(rootDir, "assets/images/splash.png"),
|
|
2016
|
+
join8(rootDir, "assets/splash/splash.png"),
|
|
2017
|
+
join8(rootDir, "assets/images/splash_screen.png"),
|
|
1709
2018
|
// flutter_native_splash output paths
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
2019
|
+
join8(rootDir, "android/app/src/main/res/drawable-xxhdpi/android12splash.png"),
|
|
2020
|
+
join8(rootDir, "android/app/src/main/res/drawable-xxxhdpi/android12splash.png"),
|
|
2021
|
+
join8(rootDir, "ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png"),
|
|
2022
|
+
join8(rootDir, "ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png"),
|
|
2023
|
+
join8(rootDir, "ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png")
|
|
1715
2024
|
);
|
|
1716
2025
|
for (const density of ["xxxhdpi", "xxhdpi", "xhdpi"]) {
|
|
1717
2026
|
paths.push(
|
|
1718
|
-
|
|
1719
|
-
|
|
2027
|
+
join8(rootDir, `android/app/src/main/res/drawable-${density}/splash.png`),
|
|
2028
|
+
join8(rootDir, `android/app/src/main/res/drawable-${density}/launch_screen.png`)
|
|
1720
2029
|
);
|
|
1721
2030
|
}
|
|
1722
2031
|
break;
|
|
1723
2032
|
case "react-native":
|
|
1724
2033
|
case "capacitor":
|
|
1725
2034
|
paths.push(
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
2035
|
+
join8(rootDir, "assets/splash.png"),
|
|
2036
|
+
join8(rootDir, "src/assets/splash.png"),
|
|
2037
|
+
join8(rootDir, "assets/images/splash.png"),
|
|
2038
|
+
join8(rootDir, "resources/splash.png")
|
|
1730
2039
|
);
|
|
1731
2040
|
break;
|
|
1732
2041
|
case "kotlin":
|
|
1733
2042
|
case "java":
|
|
1734
2043
|
for (const density of ["xxxhdpi", "xxhdpi", "xhdpi"]) {
|
|
1735
2044
|
paths.push(
|
|
1736
|
-
|
|
1737
|
-
|
|
2045
|
+
join8(rootDir, `app/src/main/res/drawable-${density}/splash.png`),
|
|
2046
|
+
join8(rootDir, `app/src/main/res/drawable-${density}/launch_screen.png`)
|
|
1738
2047
|
);
|
|
1739
2048
|
}
|
|
1740
2049
|
break;
|
|
@@ -1744,23 +2053,23 @@ function getSplashPaths(rootDir, techStack) {
|
|
|
1744
2053
|
function getFeatureGraphicPaths(rootDir, techStack) {
|
|
1745
2054
|
const paths = [];
|
|
1746
2055
|
paths.push(
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
2056
|
+
join8(rootDir, "fastlane/metadata/android/en-US/images/featureGraphic.png"),
|
|
2057
|
+
join8(rootDir, "fastlane/metadata/android/en-US/images/featureGraphic.jpg"),
|
|
2058
|
+
join8(rootDir, "metadata/android/en-US/images/featureGraphic.png")
|
|
1750
2059
|
);
|
|
1751
2060
|
switch (techStack) {
|
|
1752
2061
|
case "flutter":
|
|
1753
2062
|
case "react-native":
|
|
1754
2063
|
case "capacitor":
|
|
1755
2064
|
paths.push(
|
|
1756
|
-
|
|
1757
|
-
|
|
2065
|
+
join8(rootDir, "assets/feature_graphic.png"),
|
|
2066
|
+
join8(rootDir, "assets/feature-graphic.png")
|
|
1758
2067
|
);
|
|
1759
2068
|
break;
|
|
1760
2069
|
case "kotlin":
|
|
1761
2070
|
case "java":
|
|
1762
2071
|
paths.push(
|
|
1763
|
-
|
|
2072
|
+
join8(rootDir, "app/src/main/feature_graphic.png")
|
|
1764
2073
|
);
|
|
1765
2074
|
break;
|
|
1766
2075
|
}
|
|
@@ -1769,14 +2078,14 @@ function getFeatureGraphicPaths(rootDir, techStack) {
|
|
|
1769
2078
|
function getPromotionalPaths(rootDir, techStack) {
|
|
1770
2079
|
const paths = [];
|
|
1771
2080
|
paths.push(
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
2081
|
+
join8(rootDir, "fastlane/metadata/android/en-US/images/promoGraphic.png"),
|
|
2082
|
+
join8(rootDir, "fastlane/metadata/android/en-US/images/promoGraphic.jpg"),
|
|
2083
|
+
join8(rootDir, "assets/promo.png"),
|
|
2084
|
+
join8(rootDir, "assets/promotional.png")
|
|
1776
2085
|
);
|
|
1777
2086
|
if (techStack === "swift") {
|
|
1778
2087
|
paths.push(
|
|
1779
|
-
|
|
2088
|
+
join8(rootDir, "fastlane/metadata/en-US/promotional.png")
|
|
1780
2089
|
);
|
|
1781
2090
|
}
|
|
1782
2091
|
return paths;
|
|
@@ -1793,30 +2102,30 @@ function scanAppAssets(rootDir, techStack) {
|
|
|
1793
2102
|
collectImagesRecursive(rootDir, dir, "screenshot", assets, MAX_SCREENSHOTS, 3, 0, minSize);
|
|
1794
2103
|
}
|
|
1795
2104
|
const splashPaths = getSplashPaths(rootDir, techStack);
|
|
1796
|
-
for (const
|
|
2105
|
+
for (const path2 of splashPaths) {
|
|
1797
2106
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
1798
2107
|
if (assets.some((a) => a.asset_type === "splash_screen")) break;
|
|
1799
|
-
const asset = tryReadImage(rootDir,
|
|
2108
|
+
const asset = tryReadImage(rootDir, path2, "splash_screen");
|
|
1800
2109
|
if (asset) {
|
|
1801
2110
|
assets.push(asset);
|
|
1802
2111
|
break;
|
|
1803
2112
|
}
|
|
1804
2113
|
}
|
|
1805
2114
|
const featurePaths = getFeatureGraphicPaths(rootDir, techStack);
|
|
1806
|
-
for (const
|
|
2115
|
+
for (const path2 of featurePaths) {
|
|
1807
2116
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
1808
2117
|
if (assets.some((a) => a.asset_type === "feature_graphic")) break;
|
|
1809
|
-
const asset = tryReadImage(rootDir,
|
|
2118
|
+
const asset = tryReadImage(rootDir, path2, "feature_graphic");
|
|
1810
2119
|
if (asset) {
|
|
1811
2120
|
assets.push(asset);
|
|
1812
2121
|
break;
|
|
1813
2122
|
}
|
|
1814
2123
|
}
|
|
1815
2124
|
const promoPaths = getPromotionalPaths(rootDir, techStack);
|
|
1816
|
-
for (const
|
|
2125
|
+
for (const path2 of promoPaths) {
|
|
1817
2126
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
1818
2127
|
if (assets.some((a) => a.asset_type === "promotional_image")) break;
|
|
1819
|
-
const asset = tryReadImage(rootDir,
|
|
2128
|
+
const asset = tryReadImage(rootDir, path2, "promotional_image");
|
|
1820
2129
|
if (asset) {
|
|
1821
2130
|
assets.push(asset);
|
|
1822
2131
|
break;
|
|
@@ -1928,17 +2237,56 @@ var ForvibeClient = class {
|
|
|
1928
2237
|
}
|
|
1929
2238
|
return await response.json();
|
|
1930
2239
|
}
|
|
2240
|
+
/**
|
|
2241
|
+
* Submit codebase review results for combined analysis
|
|
2242
|
+
*/
|
|
2243
|
+
async submitCodebaseReview(review) {
|
|
2244
|
+
if (!this.sessionToken) {
|
|
2245
|
+
throw new Error("Not connected. Please validate OTC code first.");
|
|
2246
|
+
}
|
|
2247
|
+
const headers = {
|
|
2248
|
+
"Content-Type": "application/json",
|
|
2249
|
+
Authorization: `Bearer ${this.sessionToken}`
|
|
2250
|
+
};
|
|
2251
|
+
const response = await fetchWithAuth(
|
|
2252
|
+
`${this.baseUrl}/api/review/codebase`,
|
|
2253
|
+
{
|
|
2254
|
+
method: "POST",
|
|
2255
|
+
headers,
|
|
2256
|
+
body: JSON.stringify({ codebase_review: review })
|
|
2257
|
+
}
|
|
2258
|
+
);
|
|
2259
|
+
if (!response.ok) {
|
|
2260
|
+
const bodyText = await response.text().catch(() => "");
|
|
2261
|
+
let errorMessage;
|
|
2262
|
+
try {
|
|
2263
|
+
const errorJson = JSON.parse(bodyText);
|
|
2264
|
+
errorMessage = errorJson.error;
|
|
2265
|
+
} catch {
|
|
2266
|
+
}
|
|
2267
|
+
if (response.status === 401) {
|
|
2268
|
+
throw new Error(
|
|
2269
|
+
errorMessage || "Session expired. Please generate a new connection code."
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
throw new Error(
|
|
2273
|
+
errorMessage || `Review submission failed (HTTP ${response.status}: ${bodyText.substring(0, 200)})`
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
return await response.json();
|
|
2277
|
+
}
|
|
1931
2278
|
};
|
|
1932
2279
|
|
|
1933
2280
|
// src/ai/providers.ts
|
|
1934
2281
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
1935
|
-
function createGeminiProvider(apiKey) {
|
|
2282
|
+
function createGeminiProvider(apiKey, deep) {
|
|
2283
|
+
const modelId = deep ? "gemini-3.1-pro" : "gemini-3-flash";
|
|
1936
2284
|
return {
|
|
1937
|
-
name:
|
|
2285
|
+
name: `Gemini (${deep ? "3.1 Pro" : "3 Flash"})`,
|
|
1938
2286
|
async generateJSON(systemPrompt, userPrompt, temperature) {
|
|
1939
2287
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
1940
2288
|
const model = genAI.getGenerativeModel({
|
|
1941
|
-
model:
|
|
2289
|
+
model: modelId,
|
|
1942
2290
|
generationConfig: {
|
|
1943
2291
|
temperature,
|
|
1944
2292
|
responseMimeType: "application/json"
|
|
@@ -2027,7 +2375,7 @@ function createClaudeProvider(apiKey) {
|
|
|
2027
2375
|
}
|
|
2028
2376
|
};
|
|
2029
2377
|
}
|
|
2030
|
-
function getAvailableProviders() {
|
|
2378
|
+
function getAvailableProviders(deep = false) {
|
|
2031
2379
|
const providers = [];
|
|
2032
2380
|
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
2033
2381
|
if (anthropicKey) {
|
|
@@ -2051,9 +2399,9 @@ function getAvailableProviders() {
|
|
|
2051
2399
|
if (geminiKey) {
|
|
2052
2400
|
providers.push({
|
|
2053
2401
|
id: "gemini",
|
|
2054
|
-
name: "Gemini",
|
|
2402
|
+
name: deep ? "Gemini 3.1 Pro" : "Gemini 3 Flash",
|
|
2055
2403
|
recommended: false,
|
|
2056
|
-
create: () => createGeminiProvider(geminiKey)
|
|
2404
|
+
create: () => createGeminiProvider(geminiKey, deep)
|
|
2057
2405
|
});
|
|
2058
2406
|
}
|
|
2059
2407
|
return providers;
|
|
@@ -2184,6 +2532,14 @@ async function analyzeCommand(options) {
|
|
|
2184
2532
|
brandingSpinner.succeed(
|
|
2185
2533
|
`Branding: ${chalk.gray(brandingDetails || "no colors/icon detected")}`
|
|
2186
2534
|
);
|
|
2535
|
+
const fontSpinner = ora({
|
|
2536
|
+
text: "Detecting fonts...",
|
|
2537
|
+
prefixText: " "
|
|
2538
|
+
}).start();
|
|
2539
|
+
const detectedFonts = detectFonts(rootDir, techStack.stack);
|
|
2540
|
+
fontSpinner.succeed(
|
|
2541
|
+
`Fonts: ${detectedFonts.length > 0 ? chalk.white(detectedFonts.join(", ")) : chalk.gray("none detected")}`
|
|
2542
|
+
);
|
|
2187
2543
|
const assetSpinner = ora({
|
|
2188
2544
|
text: "Scanning app assets...",
|
|
2189
2545
|
prefixText: " "
|
|
@@ -2216,7 +2572,7 @@ async function analyzeCommand(options) {
|
|
|
2216
2572
|
prefixText: " "
|
|
2217
2573
|
}).start();
|
|
2218
2574
|
try {
|
|
2219
|
-
const { generateReport } = await import("./report-generator-
|
|
2575
|
+
const { generateReport } = await import("./report-generator-7E72G6KB.js");
|
|
2220
2576
|
report = await generateReport(
|
|
2221
2577
|
{ techStack, config, sdkScan, branding, readmeContent, sourceCode, projectTree },
|
|
2222
2578
|
provider
|
|
@@ -2224,6 +2580,12 @@ async function analyzeCommand(options) {
|
|
|
2224
2580
|
if (appAssets.length > 0) {
|
|
2225
2581
|
report.app_assets = appAssets;
|
|
2226
2582
|
}
|
|
2583
|
+
if (branding.brand_colors.length > 0) {
|
|
2584
|
+
report.brand_colors = branding.brand_colors;
|
|
2585
|
+
}
|
|
2586
|
+
if (detectedFonts.length > 0) {
|
|
2587
|
+
report.detected_fonts = detectedFonts;
|
|
2588
|
+
}
|
|
2227
2589
|
aiSpinner.succeed(chalk.green("Analysis complete!"));
|
|
2228
2590
|
} catch (error) {
|
|
2229
2591
|
aiSpinner.fail(
|
|
@@ -2260,7 +2622,8 @@ async function analyzeCommand(options) {
|
|
|
2260
2622
|
console.log(` Audience: ${chalk.white(report.target_audience.length > 100 ? report.target_audience.substring(0, 100) + "..." : report.target_audience)}`);
|
|
2261
2623
|
console.log(` SDKs: ${chalk.white(String(report.detected_sdks.length))} detected`);
|
|
2262
2624
|
console.log(` Data: ${chalk.white(report.data_collected.join(", ") || "none")}`);
|
|
2263
|
-
console.log(` Colors: ${chalk.hex(report.primary_color)("\u25A0")} ${report.primary_color} ${report.secondary_color ? `${chalk.hex(report.secondary_color)("\u25A0")} ${report.secondary_color}` : ""}`);
|
|
2625
|
+
console.log(` Colors: ${chalk.hex(report.primary_color)("\u25A0")} ${report.primary_color} ${report.secondary_color ? `${chalk.hex(report.secondary_color)("\u25A0")} ${report.secondary_color}` : ""}${report.brand_colors?.length ? chalk.gray(` +${report.brand_colors.length} brand colors`) : ""}`);
|
|
2626
|
+
console.log(` Fonts: ${report.detected_fonts?.length ? chalk.white(report.detected_fonts.join(", ")) : chalk.gray("none detected")}`);
|
|
2264
2627
|
console.log(` Icon: ${report.app_icon_base64 ? chalk.green("\u2713 found") : chalk.gray("not found")}`);
|
|
2265
2628
|
console.log(` Assets: ${report.app_assets?.length ? chalk.green(`${report.app_assets.length} found`) : chalk.gray("none")}`);
|
|
2266
2629
|
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"));
|
|
@@ -2288,6 +2651,53 @@ async function analyzeCommand(options) {
|
|
|
2288
2651
|
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"));
|
|
2289
2652
|
}
|
|
2290
2653
|
console.log();
|
|
2654
|
+
console.log(chalk.bold(" \u{1F4DD} App Review Information"));
|
|
2655
|
+
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"));
|
|
2656
|
+
console.log(chalk.gray(" Apple's review team may need to sign in to your app."));
|
|
2657
|
+
console.log(chalk.gray(" You can also provide this later in the Forvibe dashboard."));
|
|
2658
|
+
console.log();
|
|
2659
|
+
const hasAuthSDKs = (sdkScan.third_party_services.auth?.length ?? 0) > 0;
|
|
2660
|
+
if (hasAuthSDKs) {
|
|
2661
|
+
const signInAnswer = await askQuestion(
|
|
2662
|
+
chalk.cyan(" Does your app require sign-in to use? (y/n): ")
|
|
2663
|
+
);
|
|
2664
|
+
const signInRequired = signInAnswer.toLowerCase().startsWith("y");
|
|
2665
|
+
report.sign_in_required = signInRequired;
|
|
2666
|
+
if (signInRequired) {
|
|
2667
|
+
console.log();
|
|
2668
|
+
console.log(chalk.yellow(" \u26A0 Please create a test account in your app for Apple's review team."));
|
|
2669
|
+
console.log(chalk.gray(" This account should have full access to your app's features."));
|
|
2670
|
+
console.log();
|
|
2671
|
+
const testEmail = await askQuestion(chalk.cyan(" Test account email: "));
|
|
2672
|
+
const testPassword = await askQuestion(chalk.cyan(" Test account password: "));
|
|
2673
|
+
if (testEmail && testPassword) {
|
|
2674
|
+
report.test_account = { email: testEmail, password: testPassword };
|
|
2675
|
+
console.log(chalk.green(" \u2713 Test account saved"));
|
|
2676
|
+
}
|
|
2677
|
+
}
|
|
2678
|
+
} else {
|
|
2679
|
+
report.sign_in_required = false;
|
|
2680
|
+
}
|
|
2681
|
+
console.log();
|
|
2682
|
+
console.log(chalk.gray(" Contact information for Apple's review team (press Enter to skip):"));
|
|
2683
|
+
console.log();
|
|
2684
|
+
const firstName = await askQuestion(chalk.cyan(" First name: "));
|
|
2685
|
+
const lastName = await askQuestion(chalk.cyan(" Last name: "));
|
|
2686
|
+
const contactEmail = await askQuestion(chalk.cyan(" Email: "));
|
|
2687
|
+
const contactPhone = await askQuestion(chalk.cyan(" Phone (e.g. +1 555 123 4567): "));
|
|
2688
|
+
if (firstName && lastName && contactEmail) {
|
|
2689
|
+
report.review_contact = {
|
|
2690
|
+
first_name: firstName,
|
|
2691
|
+
last_name: lastName,
|
|
2692
|
+
email: contactEmail,
|
|
2693
|
+
phone: contactPhone || ""
|
|
2694
|
+
};
|
|
2695
|
+
console.log(chalk.green(" \u2713 Contact info saved"));
|
|
2696
|
+
} else {
|
|
2697
|
+
console.log(chalk.gray(" \u2139 Skipped \u2014 you can add this later in the dashboard."));
|
|
2698
|
+
}
|
|
2699
|
+
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"));
|
|
2700
|
+
console.log();
|
|
2291
2701
|
const sendSpinner = ora({
|
|
2292
2702
|
text: "Sending report to Forvibe...",
|
|
2293
2703
|
prefixText: " "
|
|
@@ -2310,6 +2720,848 @@ async function analyzeCommand(options) {
|
|
|
2310
2720
|
}
|
|
2311
2721
|
}
|
|
2312
2722
|
|
|
2723
|
+
// src/commands/review.ts
|
|
2724
|
+
import { createInterface as createInterface2 } from "readline";
|
|
2725
|
+
import chalk3 from "chalk";
|
|
2726
|
+
import ora2 from "ora";
|
|
2727
|
+
|
|
2728
|
+
// src/review/feature-detector.ts
|
|
2729
|
+
function getFeatureDiscoveryPrompt() {
|
|
2730
|
+
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.
|
|
2731
|
+
|
|
2732
|
+
Respond in English.
|
|
2733
|
+
|
|
2734
|
+
Answer this question: "If someone downloads this app and opens it on their iPhone, what will they see and experience?"
|
|
2735
|
+
|
|
2736
|
+
Analyze the source code and identify:
|
|
2737
|
+
|
|
2738
|
+
1. **App Purpose**: What does this app do? (1-2 sentences)
|
|
2739
|
+
2. **User Flows**: What are the main things a user can do? (list each)
|
|
2740
|
+
3. **Onboarding**: What happens when you first open the app? Login required? Permissions asked immediately?
|
|
2741
|
+
4. **Monetization**: How does the app make money? Subscriptions? IAP? Ads? External payments? Free?
|
|
2742
|
+
5. **Data & Privacy**: What user data does the app collect? What device permissions does it need? Is there a privacy policy link?
|
|
2743
|
+
6. **Content**: Is there user-generated content (posts, comments, chat)? Third-party content? Web content?
|
|
2744
|
+
7. **Account Management**: Can users create accounts? Can they delete their accounts?
|
|
2745
|
+
8. **Platform Features**: Does it use WebViews, widgets, extensions, push notifications, background processing?
|
|
2746
|
+
9. **Settings & Legal**: What can users configure? Is there a privacy policy accessible in-app? Terms of service?
|
|
2747
|
+
10. **External Links**: Does the app link to external payment pages, websites, or other apps?
|
|
2748
|
+
|
|
2749
|
+
IMPORTANT: You are seeing a SUBSET of the source code, not the entire codebase. Do not make definitive claims about features being absent \u2014 only report features you can positively identify from the code shown. If something might exist but you don't see it, do not list it as missing.
|
|
2750
|
+
|
|
2751
|
+
Respond ONLY with valid JSON in this exact format:
|
|
2752
|
+
|
|
2753
|
+
{
|
|
2754
|
+
"appPurpose": "string describing what the app does",
|
|
2755
|
+
"features": [
|
|
2756
|
+
{
|
|
2757
|
+
"feature": "string identifier (e.g., 'subscriptions', 'webview', 'ugc', 'account_creation', 'push_notifications', 'camera_usage', 'location_tracking')",
|
|
2758
|
+
"description": "string describing what the user experiences",
|
|
2759
|
+
"confidence": 0.0-1.0,
|
|
2760
|
+
"keywords": ["relevant", "keywords", "from", "code"],
|
|
2761
|
+
"category": "safety|performance|business|design|legal|common-pitfalls",
|
|
2762
|
+
"relevantFiles": ["file1.swift", "file2.ts"]
|
|
2763
|
+
}
|
|
2764
|
+
],
|
|
2765
|
+
"riskAreas": ["string describing areas that Apple reviewers commonly flag for apps like this"],
|
|
2766
|
+
"appType": "native|hybrid|webview-heavy|web-wrapper"
|
|
2767
|
+
}`;
|
|
2768
|
+
}
|
|
2769
|
+
async function detectFeatures(provider, platform, appName, bundleId, sourceCode) {
|
|
2770
|
+
const systemPrompt = getFeatureDiscoveryPrompt();
|
|
2771
|
+
const userPrompt = `## App Information
|
|
2772
|
+
- Platform: ${platform}
|
|
2773
|
+
- App Name: ${appName}
|
|
2774
|
+
- Bundle ID: ${bundleId}
|
|
2775
|
+
|
|
2776
|
+
## Source Code
|
|
2777
|
+
|
|
2778
|
+
${sourceCode}`;
|
|
2779
|
+
try {
|
|
2780
|
+
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
2781
|
+
return parseFeatureAnalysis(response);
|
|
2782
|
+
} catch {
|
|
2783
|
+
return null;
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
function parseFeatureAnalysis(response) {
|
|
2787
|
+
try {
|
|
2788
|
+
return JSON.parse(response);
|
|
2789
|
+
} catch {
|
|
2790
|
+
const jsonMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
2791
|
+
if (jsonMatch) {
|
|
2792
|
+
try {
|
|
2793
|
+
return JSON.parse(jsonMatch[1]);
|
|
2794
|
+
} catch {
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
const objectMatch = response.match(/\{[\s\S]*"features"[\s\S]*\}/);
|
|
2798
|
+
if (objectMatch) {
|
|
2799
|
+
try {
|
|
2800
|
+
return JSON.parse(objectMatch[0]);
|
|
2801
|
+
} catch {
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
return null;
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
// src/review/rag-selector.ts
|
|
2809
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
2810
|
+
import { fileURLToPath } from "url";
|
|
2811
|
+
import path from "path";
|
|
2812
|
+
var DATA_FILES = [
|
|
2813
|
+
"safety.json",
|
|
2814
|
+
"performance.json",
|
|
2815
|
+
"business.json",
|
|
2816
|
+
"design.json",
|
|
2817
|
+
"legal.json",
|
|
2818
|
+
"common-pitfalls.json"
|
|
2819
|
+
];
|
|
2820
|
+
var cachedStories = null;
|
|
2821
|
+
function getDataDir() {
|
|
2822
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
2823
|
+
const __dirname = path.dirname(__filename);
|
|
2824
|
+
const candidates = [
|
|
2825
|
+
path.join(__dirname, "rag-data"),
|
|
2826
|
+
path.join(__dirname, "review", "rag-data"),
|
|
2827
|
+
path.join(__dirname, "..", "review", "rag-data"),
|
|
2828
|
+
path.join(__dirname, "..", "src", "review", "rag-data")
|
|
2829
|
+
];
|
|
2830
|
+
for (const dir of candidates) {
|
|
2831
|
+
try {
|
|
2832
|
+
readFileSync5(path.join(dir, "safety.json"));
|
|
2833
|
+
return dir;
|
|
2834
|
+
} catch {
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
return candidates[0];
|
|
2838
|
+
}
|
|
2839
|
+
function loadAllStories() {
|
|
2840
|
+
if (cachedStories) return cachedStories;
|
|
2841
|
+
const dataDir = getDataDir();
|
|
2842
|
+
const stories = [];
|
|
2843
|
+
for (const file of DATA_FILES) {
|
|
2844
|
+
try {
|
|
2845
|
+
const content = readFileSync5(path.join(dataDir, file), "utf-8");
|
|
2846
|
+
const parsed = JSON.parse(content);
|
|
2847
|
+
stories.push(...parsed);
|
|
2848
|
+
} catch {
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
cachedStories = stories;
|
|
2852
|
+
return stories;
|
|
2853
|
+
}
|
|
2854
|
+
function searchStories(signals, maxResults = 20) {
|
|
2855
|
+
const allStories = loadAllStories();
|
|
2856
|
+
if (signals.length === 0) return allStories.slice(0, maxResults);
|
|
2857
|
+
const signalKeywords = new Set(
|
|
2858
|
+
signals.flatMap((s) => s.keywords.map((k) => k.toLowerCase()))
|
|
2859
|
+
);
|
|
2860
|
+
const signalFeatures = new Set(
|
|
2861
|
+
signals.map((s) => s.feature.toLowerCase())
|
|
2862
|
+
);
|
|
2863
|
+
const signalCategories = new Set(signals.map((s) => s.category));
|
|
2864
|
+
const scored = allStories.map((story) => {
|
|
2865
|
+
let score = 0;
|
|
2866
|
+
for (const signal of story.behavioralSignals) {
|
|
2867
|
+
const signalLower = signal.toLowerCase();
|
|
2868
|
+
for (const feature of signalFeatures) {
|
|
2869
|
+
if (signalLower.includes(feature) || feature.includes(signalLower.split(" ")[0])) {
|
|
2870
|
+
score += 3;
|
|
2871
|
+
break;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
for (const keyword of story.keywords) {
|
|
2876
|
+
if (signalKeywords.has(keyword.toLowerCase())) {
|
|
2877
|
+
score += 2;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
if (signalCategories.has(story.category)) {
|
|
2881
|
+
score += 1;
|
|
2882
|
+
}
|
|
2883
|
+
return { story, score };
|
|
2884
|
+
});
|
|
2885
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, maxResults).map((s) => s.story);
|
|
2886
|
+
}
|
|
2887
|
+
function formatStoriesForPrompt(stories) {
|
|
2888
|
+
if (stories.length === 0) return "No relevant rejection stories found.";
|
|
2889
|
+
return stories.map((s, i) => {
|
|
2890
|
+
return `### Case ${i + 1}: ${s.guidelineName} (Guideline ${s.guidelineNumber})
|
|
2891
|
+
**What happened:** ${s.whatDeveloperDid}
|
|
2892
|
+
**Apple said:** "${s.whatAppleSaid}"
|
|
2893
|
+
**The fix:** ${s.fix}
|
|
2894
|
+
**User-visible signs:** ${s.behavioralSignals.join("; ")}`;
|
|
2895
|
+
}).join("\n\n");
|
|
2896
|
+
}
|
|
2897
|
+
|
|
2898
|
+
// src/review/reviewer.ts
|
|
2899
|
+
function getReviewerSimulationPrompt() {
|
|
2900
|
+
return `You are a senior member of Apple's App Review team. You have personally reviewed thousands of apps and know exactly what causes rejections.
|
|
2901
|
+
|
|
2902
|
+
Write all findings in English.
|
|
2903
|
+
|
|
2904
|
+
## YOUR MINDSET
|
|
2905
|
+
|
|
2906
|
+
You do NOT review code quality. You review the APP \u2014 what a user sees, touches, and experiences. You are testing this app on a real device. You are tapping buttons, reading screens, navigating flows, and checking if the experience meets Apple's standards.
|
|
2907
|
+
|
|
2908
|
+
For every issue, think through this exact scenario:
|
|
2909
|
+
"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]."
|
|
2910
|
+
|
|
2911
|
+
## WHAT YOU CHECK (as if using the app)
|
|
2912
|
+
|
|
2913
|
+
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?
|
|
2914
|
+
|
|
2915
|
+
2. **Account Flow**: If I create an account, can I also delete it? Is the delete option easy to find? Does the app force registration for basic features?
|
|
2916
|
+
|
|
2917
|
+
3. **Payment Experience**: If there are subscriptions/purchases:
|
|
2918
|
+
- Are the price, duration, and auto-renewal terms clearly shown BEFORE I tap purchase?
|
|
2919
|
+
- Is there a "Restore Purchases" button I can find?
|
|
2920
|
+
- Am I being sent to a website to pay for digital content? (That's a rejection)
|
|
2921
|
+
|
|
2922
|
+
4. **Privacy Experience**:
|
|
2923
|
+
- Does the app explain WHY it needs each permission?
|
|
2924
|
+
- Can I find a privacy policy in the app?
|
|
2925
|
+
- Does it ask for App Tracking permission before tracking me?
|
|
2926
|
+
|
|
2927
|
+
5. **Content & Safety**:
|
|
2928
|
+
- If users can post content, can I report/block other users?
|
|
2929
|
+
- Is there inappropriate content without proper age rating?
|
|
2930
|
+
|
|
2931
|
+
6. **App Quality**:
|
|
2932
|
+
- Is this just a website in a wrapper? (WebView with no native features = rejection)
|
|
2933
|
+
- Does it feel like a real app or a web page?
|
|
2934
|
+
- Is there placeholder text, "Coming Soon" sections, or test content?
|
|
2935
|
+
|
|
2936
|
+
7. **Navigation & UX**:
|
|
2937
|
+
- Can I get back to the main screen?
|
|
2938
|
+
- Does the app follow iOS conventions?
|
|
2939
|
+
|
|
2940
|
+
## RESPONSE FORMAT
|
|
2941
|
+
|
|
2942
|
+
You MUST respond with valid JSON:
|
|
2943
|
+
|
|
2944
|
+
{
|
|
2945
|
+
"findings": [
|
|
2946
|
+
{
|
|
2947
|
+
"ruleId": "string (kebab-case, e.g., 'missing-account-deletion')",
|
|
2948
|
+
"guidelineNumber": "string (e.g., '5.1.1')",
|
|
2949
|
+
"guidelineName": "string",
|
|
2950
|
+
"severity": "high | medium | low | info",
|
|
2951
|
+
"category": "safety | performance | business | design | legal | common-pitfalls",
|
|
2952
|
+
"title": "string (e.g., 'Missing Account Deletion Option')",
|
|
2953
|
+
"description": "string (detailed description of the issue)",
|
|
2954
|
+
"userImpact": "string (what the user actually experiences \u2014 'When I tap Settings, there is no option to delete my account')",
|
|
2955
|
+
"evidence": "string (code/config evidence found)",
|
|
2956
|
+
"file": "string | null (file where evidence was found)",
|
|
2957
|
+
"fixSuggestion": "string (specific actionable fix)",
|
|
2958
|
+
"relatedRejection": "string | null (reference to a similar rejection case if provided)"
|
|
2959
|
+
}
|
|
2960
|
+
],
|
|
2961
|
+
"overallAssessment": "string (2-3 sentence assessment of the app's review readiness)",
|
|
2962
|
+
"estimatedOutcome": "likely-approved | likely-rejected | needs-attention",
|
|
2963
|
+
"estimatedApprovalChance": 0-100
|
|
2964
|
+
}
|
|
2965
|
+
|
|
2966
|
+
## IMPORTANT RULES
|
|
2967
|
+
|
|
2968
|
+
1. Every finding MUST describe user-visible behavior, not code patterns
|
|
2969
|
+
2. The "userImpact" field is MANDATORY \u2014 describe what a person using the app would experience
|
|
2970
|
+
3. Be REALISTIC \u2014 don't fabricate issues. If the app looks clean, say so
|
|
2971
|
+
4. Reference the rejection stories provided \u2014 they show what Apple ACTUALLY enforces
|
|
2972
|
+
5. Severity guide:
|
|
2973
|
+
- high: Apple would very likely reject for this (based on real cases)
|
|
2974
|
+
- medium: Apple might reject, especially if reviewer is strict
|
|
2975
|
+
- low: Improves chances but not a guaranteed rejection
|
|
2976
|
+
- info: Best practice suggestion
|
|
2977
|
+
6. Focus on the TOP issues (max 10 findings). Quality over quantity.
|
|
2978
|
+
7. CRITICAL: You are seeing a SUBSET of the codebase, not ALL of it. If you cannot find evidence of something (e.g., a "Restore Purchases" button, account deletion flow, privacy policy link), do NOT assume it is missing. Only flag it if you see CONTRADICTING evidence (e.g., subscription code exists but zero restore mechanism in ANY of the visible code). When uncertain, use severity "low" or "info" instead of "high".
|
|
2979
|
+
8. Never flag an issue as "high" unless you have DIRECT code evidence that something is definitively wrong or missing. "I didn't see it in the provided code" is NOT sufficient evidence for a high-risk finding \u2014 the feature may exist in files not shown to you.`;
|
|
2980
|
+
}
|
|
2981
|
+
function parseAIResponse(response) {
|
|
2982
|
+
try {
|
|
2983
|
+
return JSON.parse(response);
|
|
2984
|
+
} catch {
|
|
2985
|
+
const jsonMatch = response.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
|
|
2986
|
+
if (jsonMatch) {
|
|
2987
|
+
try {
|
|
2988
|
+
return JSON.parse(jsonMatch[1]);
|
|
2989
|
+
} catch {
|
|
2990
|
+
}
|
|
2991
|
+
}
|
|
2992
|
+
const objectMatch = response.match(/\{[\s\S]*"findings"[\s\S]*\}/);
|
|
2993
|
+
if (objectMatch) {
|
|
2994
|
+
try {
|
|
2995
|
+
return JSON.parse(objectMatch[0]);
|
|
2996
|
+
} catch {
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
return null;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
function validateRiskLevel(s) {
|
|
3003
|
+
if (["high", "medium", "low", "info"].includes(s)) return s;
|
|
3004
|
+
if (s === "rejection") return "high";
|
|
3005
|
+
if (s === "warning") return "medium";
|
|
3006
|
+
if (s === "recommendation") return "low";
|
|
3007
|
+
return "medium";
|
|
3008
|
+
}
|
|
3009
|
+
function validateCategory(c) {
|
|
3010
|
+
if (["safety", "performance", "business", "design", "legal", "common-pitfalls"].includes(c)) {
|
|
3011
|
+
return c;
|
|
3012
|
+
}
|
|
3013
|
+
return "performance";
|
|
3014
|
+
}
|
|
3015
|
+
function validateOutcome(o) {
|
|
3016
|
+
if (["likely-approved", "likely-rejected", "needs-attention"].includes(o)) {
|
|
3017
|
+
return o;
|
|
3018
|
+
}
|
|
3019
|
+
return "needs-attention";
|
|
3020
|
+
}
|
|
3021
|
+
function convertFindings(raw) {
|
|
3022
|
+
return raw.findings.map((f, i) => ({
|
|
3023
|
+
id: f.ruleId || `finding-${i + 1}`,
|
|
3024
|
+
title: f.title,
|
|
3025
|
+
risk_level: validateRiskLevel(f.severity),
|
|
3026
|
+
category: validateCategory(f.category),
|
|
3027
|
+
source: "codebase",
|
|
3028
|
+
guideline_number: f.guidelineNumber,
|
|
3029
|
+
guideline_name: f.guidelineName,
|
|
3030
|
+
description: f.description,
|
|
3031
|
+
user_impact: f.userImpact ?? f.description,
|
|
3032
|
+
evidence: f.evidence ?? "",
|
|
3033
|
+
recommendation: f.fixSuggestion,
|
|
3034
|
+
file: f.file ?? void 0,
|
|
3035
|
+
related_story_id: f.relatedRejection ?? void 0
|
|
3036
|
+
}));
|
|
3037
|
+
}
|
|
3038
|
+
function buildSummary(findings, aiResponse) {
|
|
3039
|
+
const high = findings.filter((f) => f.risk_level === "high").length;
|
|
3040
|
+
const medium = findings.filter((f) => f.risk_level === "medium").length;
|
|
3041
|
+
const low = findings.filter((f) => f.risk_level === "low").length;
|
|
3042
|
+
const info = findings.filter((f) => f.risk_level === "info").length;
|
|
3043
|
+
const outcome = validateOutcome(aiResponse.estimatedOutcome);
|
|
3044
|
+
let approvalChance = aiResponse.estimatedApprovalChance ?? 75;
|
|
3045
|
+
if (outcome === "likely-rejected") approvalChance = Math.min(approvalChance, 30);
|
|
3046
|
+
if (outcome === "likely-approved") approvalChance = Math.max(approvalChance, 70);
|
|
3047
|
+
const topRisks = findings.filter((f) => f.risk_level === "high" || f.risk_level === "medium").slice(0, 3).map((f) => f.title);
|
|
3048
|
+
return {
|
|
3049
|
+
total_findings: findings.length,
|
|
3050
|
+
high_risk: high,
|
|
3051
|
+
medium_risk: medium,
|
|
3052
|
+
low_risk: low,
|
|
3053
|
+
info,
|
|
3054
|
+
outcome,
|
|
3055
|
+
estimated_approval_chance: approvalChance,
|
|
3056
|
+
top_risks: topRisks,
|
|
3057
|
+
ai_assessment: aiResponse.overallAssessment
|
|
3058
|
+
};
|
|
3059
|
+
}
|
|
3060
|
+
async function runCodeReview(provider, platform, appName, bundleId, sourceCode, sourceFilesCount, deep) {
|
|
3061
|
+
const startTime = Date.now();
|
|
3062
|
+
let featureAnalysis = null;
|
|
3063
|
+
let relevantStories;
|
|
3064
|
+
if (deep) {
|
|
3065
|
+
featureAnalysis = await detectFeatures(
|
|
3066
|
+
provider,
|
|
3067
|
+
platform,
|
|
3068
|
+
appName,
|
|
3069
|
+
bundleId,
|
|
3070
|
+
sourceCode
|
|
3071
|
+
);
|
|
3072
|
+
if (featureAnalysis) {
|
|
3073
|
+
relevantStories = searchStories(featureAnalysis.features, 20);
|
|
3074
|
+
} else {
|
|
3075
|
+
relevantStories = loadAllStories().slice(0, 15);
|
|
3076
|
+
}
|
|
3077
|
+
} else {
|
|
3078
|
+
relevantStories = loadAllStories().slice(0, 15);
|
|
3079
|
+
}
|
|
3080
|
+
const storiesText = formatStoriesForPrompt(relevantStories);
|
|
3081
|
+
const featureSection = featureAnalysis ? `## App Analysis (from feature discovery)
|
|
3082
|
+
|
|
3083
|
+
**App Purpose:** ${featureAnalysis.appPurpose}
|
|
3084
|
+
**App Type:** ${featureAnalysis.appType}
|
|
3085
|
+
|
|
3086
|
+
**Detected Features:**
|
|
3087
|
+
${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confidence: ${f.confidence})`).join("\n")}
|
|
3088
|
+
|
|
3089
|
+
**Risk Areas:**
|
|
3090
|
+
${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}` : "";
|
|
3091
|
+
const userPrompt = `## App Information
|
|
3092
|
+
- Platform: ${platform}
|
|
3093
|
+
- App Name: ${appName}
|
|
3094
|
+
- Bundle ID: ${bundleId}
|
|
3095
|
+
|
|
3096
|
+
${featureSection}
|
|
3097
|
+
|
|
3098
|
+
## Real Rejection Cases (from other developers)
|
|
3099
|
+
These are REAL cases where Apple rejected similar apps. Use them to calibrate:
|
|
3100
|
+
|
|
3101
|
+
${storiesText}
|
|
3102
|
+
|
|
3103
|
+
## Source Code
|
|
3104
|
+
|
|
3105
|
+
${sourceCode}
|
|
3106
|
+
|
|
3107
|
+
Now review this app as an Apple reviewer testing it on a device. What would you flag?`;
|
|
3108
|
+
const systemPrompt = getReviewerSimulationPrompt();
|
|
3109
|
+
const response = await provider.generateJSON(systemPrompt, userPrompt, 0.2);
|
|
3110
|
+
const parsed = parseAIResponse(response);
|
|
3111
|
+
if (!parsed) {
|
|
3112
|
+
return {
|
|
3113
|
+
features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
|
|
3114
|
+
source_files_analyzed: sourceFilesCount,
|
|
3115
|
+
platform,
|
|
3116
|
+
bundle_id: bundleId || null,
|
|
3117
|
+
findings: [],
|
|
3118
|
+
summary: {
|
|
3119
|
+
total_findings: 0,
|
|
3120
|
+
high_risk: 0,
|
|
3121
|
+
medium_risk: 0,
|
|
3122
|
+
low_risk: 0,
|
|
3123
|
+
info: 0,
|
|
3124
|
+
outcome: "needs-attention",
|
|
3125
|
+
estimated_approval_chance: 50,
|
|
3126
|
+
top_risks: [],
|
|
3127
|
+
ai_assessment: "AI analysis could not produce structured results."
|
|
3128
|
+
},
|
|
3129
|
+
rag_stories_used: relevantStories.length,
|
|
3130
|
+
scan_duration_ms: Date.now() - startTime
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
const findings = convertFindings(parsed);
|
|
3134
|
+
const summary = buildSummary(findings, parsed);
|
|
3135
|
+
return {
|
|
3136
|
+
features_detected: featureAnalysis?.features.map((f) => f.feature) ?? [],
|
|
3137
|
+
source_files_analyzed: sourceFilesCount,
|
|
3138
|
+
platform,
|
|
3139
|
+
bundle_id: bundleId || null,
|
|
3140
|
+
findings,
|
|
3141
|
+
summary,
|
|
3142
|
+
rag_stories_used: relevantStories.length,
|
|
3143
|
+
scan_duration_ms: Date.now() - startTime
|
|
3144
|
+
};
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// src/review/formatters.ts
|
|
3148
|
+
import chalk2 from "chalk";
|
|
3149
|
+
var RISK_ICONS = {
|
|
3150
|
+
high: "\u{1F534}",
|
|
3151
|
+
medium: "\u{1F7E1}",
|
|
3152
|
+
low: "\u{1F535}",
|
|
3153
|
+
info: "\u26AA"
|
|
3154
|
+
};
|
|
3155
|
+
var RISK_LABELS = {
|
|
3156
|
+
high: "HIGH RISK",
|
|
3157
|
+
medium: "MEDIUM",
|
|
3158
|
+
low: "LOW",
|
|
3159
|
+
info: "INFO"
|
|
3160
|
+
};
|
|
3161
|
+
var RISK_COLORS = {
|
|
3162
|
+
high: chalk2.red,
|
|
3163
|
+
medium: chalk2.yellow,
|
|
3164
|
+
low: chalk2.blue,
|
|
3165
|
+
info: chalk2.gray
|
|
3166
|
+
};
|
|
3167
|
+
function outcomeDisplay(outcome) {
|
|
3168
|
+
switch (outcome) {
|
|
3169
|
+
case "likely-approved":
|
|
3170
|
+
return chalk2.green.bold("LIKELY APPROVED");
|
|
3171
|
+
case "likely-rejected":
|
|
3172
|
+
return chalk2.red.bold("LIKELY REJECTED");
|
|
3173
|
+
case "needs-attention":
|
|
3174
|
+
return chalk2.yellow.bold("NEEDS ATTENTION");
|
|
3175
|
+
default:
|
|
3176
|
+
return chalk2.gray(outcome);
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
3179
|
+
function approvalBar(chance) {
|
|
3180
|
+
const width = 20;
|
|
3181
|
+
const filled = Math.round(chance / 100 * width);
|
|
3182
|
+
const empty = width - filled;
|
|
3183
|
+
let color;
|
|
3184
|
+
if (chance >= 70) color = chalk2.green;
|
|
3185
|
+
else if (chance >= 40) color = chalk2.yellow;
|
|
3186
|
+
else color = chalk2.red;
|
|
3187
|
+
return color("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty)) + ` ${chance}%`;
|
|
3188
|
+
}
|
|
3189
|
+
function formatTerminal(report) {
|
|
3190
|
+
const lines = [];
|
|
3191
|
+
lines.push("");
|
|
3192
|
+
lines.push(chalk2.bold(" \u{1F6E1}\uFE0F App Store Review Simulation"));
|
|
3193
|
+
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"));
|
|
3194
|
+
lines.push("");
|
|
3195
|
+
lines.push(` Platform: ${chalk2.white(report.platform)}`);
|
|
3196
|
+
if (report.bundle_id) {
|
|
3197
|
+
lines.push(` Bundle ID: ${chalk2.white(report.bundle_id)}`);
|
|
3198
|
+
}
|
|
3199
|
+
lines.push(` Files: ${chalk2.white(String(report.source_files_analyzed))} source files analyzed`);
|
|
3200
|
+
lines.push(` Stories: ${chalk2.white(String(report.rag_stories_used))} rejection cases referenced`);
|
|
3201
|
+
lines.push(` Duration: ${chalk2.white((report.scan_duration_ms / 1e3).toFixed(1) + "s")}`);
|
|
3202
|
+
lines.push("");
|
|
3203
|
+
lines.push(` Outcome: ${outcomeDisplay(report.summary.outcome)}`);
|
|
3204
|
+
lines.push(` Approval: ${approvalBar(report.summary.estimated_approval_chance)}`);
|
|
3205
|
+
lines.push("");
|
|
3206
|
+
const counts = [
|
|
3207
|
+
report.summary.high_risk > 0 && chalk2.red(`${report.summary.high_risk} high risk`),
|
|
3208
|
+
report.summary.medium_risk > 0 && chalk2.yellow(`${report.summary.medium_risk} medium`),
|
|
3209
|
+
report.summary.low_risk > 0 && chalk2.blue(`${report.summary.low_risk} low`),
|
|
3210
|
+
report.summary.info > 0 && chalk2.gray(`${report.summary.info} info`)
|
|
3211
|
+
].filter(Boolean);
|
|
3212
|
+
lines.push(` Findings: ${chalk2.bold(String(report.summary.total_findings))} total \u2014 ${counts.join(" \xB7 ")}`);
|
|
3213
|
+
lines.push("");
|
|
3214
|
+
if (report.features_detected.length > 0) {
|
|
3215
|
+
lines.push(` Features: ${chalk2.gray(report.features_detected.join(", "))}`);
|
|
3216
|
+
lines.push("");
|
|
3217
|
+
}
|
|
3218
|
+
if (report.summary.ai_assessment) {
|
|
3219
|
+
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"));
|
|
3220
|
+
lines.push(` ${chalk2.italic(report.summary.ai_assessment)}`);
|
|
3221
|
+
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"));
|
|
3222
|
+
lines.push("");
|
|
3223
|
+
}
|
|
3224
|
+
const findingsByRisk = {
|
|
3225
|
+
high: [],
|
|
3226
|
+
medium: [],
|
|
3227
|
+
low: [],
|
|
3228
|
+
info: []
|
|
3229
|
+
};
|
|
3230
|
+
for (const finding of report.findings) {
|
|
3231
|
+
findingsByRisk[finding.risk_level].push(finding);
|
|
3232
|
+
}
|
|
3233
|
+
for (const level of ["high", "medium", "low", "info"]) {
|
|
3234
|
+
const findings = findingsByRisk[level];
|
|
3235
|
+
if (findings.length === 0) continue;
|
|
3236
|
+
for (const finding of findings) {
|
|
3237
|
+
const icon = RISK_ICONS[level];
|
|
3238
|
+
const label = RISK_LABELS[level];
|
|
3239
|
+
const color = RISK_COLORS[level];
|
|
3240
|
+
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"));
|
|
3241
|
+
lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
|
|
3242
|
+
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"));
|
|
3243
|
+
lines.push("");
|
|
3244
|
+
lines.push(` ${chalk2.bold(finding.title)}`);
|
|
3245
|
+
lines.push(` ${finding.description}`);
|
|
3246
|
+
lines.push("");
|
|
3247
|
+
lines.push(` ${chalk2.cyan("User Impact:")} ${finding.user_impact}`);
|
|
3248
|
+
if (finding.file) {
|
|
3249
|
+
lines.push(` ${chalk2.cyan("File:")} ${finding.file}`);
|
|
3250
|
+
}
|
|
3251
|
+
lines.push(` ${chalk2.green("Fix:")} ${finding.recommendation}`);
|
|
3252
|
+
if (finding.related_story_id) {
|
|
3253
|
+
lines.push(` ${chalk2.gray("Related case:")} ${finding.related_story_id}`);
|
|
3254
|
+
}
|
|
3255
|
+
lines.push("");
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
if (report.findings.length === 0) {
|
|
3259
|
+
lines.push(chalk2.green(" \u2713 No significant issues found. Your app looks ready for review!"));
|
|
3260
|
+
lines.push("");
|
|
3261
|
+
}
|
|
3262
|
+
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"));
|
|
3263
|
+
lines.push(chalk2.gray(" Powered by Forvibe CLI \u2014 forvibe.app"));
|
|
3264
|
+
lines.push("");
|
|
3265
|
+
return lines.join("\n");
|
|
3266
|
+
}
|
|
3267
|
+
function formatJSON(report) {
|
|
3268
|
+
return JSON.stringify(report, null, 2);
|
|
3269
|
+
}
|
|
3270
|
+
function formatMarkdown(report) {
|
|
3271
|
+
const lines = [];
|
|
3272
|
+
lines.push("# App Store Review Simulation Report");
|
|
3273
|
+
lines.push("");
|
|
3274
|
+
lines.push(`**Platform:** ${report.platform}`);
|
|
3275
|
+
if (report.bundle_id) {
|
|
3276
|
+
lines.push(`**Bundle ID:** ${report.bundle_id}`);
|
|
3277
|
+
}
|
|
3278
|
+
lines.push(`**Files Analyzed:** ${report.source_files_analyzed}`);
|
|
3279
|
+
lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
|
|
3280
|
+
lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
|
|
3281
|
+
lines.push("");
|
|
3282
|
+
lines.push("## Result");
|
|
3283
|
+
lines.push("");
|
|
3284
|
+
lines.push(`**Outcome:** ${report.summary.outcome.replace(/-/g, " ").toUpperCase()}`);
|
|
3285
|
+
lines.push(`**Estimated Approval Chance:** ${report.summary.estimated_approval_chance}%`);
|
|
3286
|
+
lines.push("");
|
|
3287
|
+
lines.push(`| Risk Level | Count |`);
|
|
3288
|
+
lines.push(`|-----------|-------|`);
|
|
3289
|
+
lines.push(`| High | ${report.summary.high_risk} |`);
|
|
3290
|
+
lines.push(`| Medium | ${report.summary.medium_risk} |`);
|
|
3291
|
+
lines.push(`| Low | ${report.summary.low_risk} |`);
|
|
3292
|
+
lines.push(`| Info | ${report.summary.info} |`);
|
|
3293
|
+
lines.push(`| **Total** | **${report.summary.total_findings}** |`);
|
|
3294
|
+
lines.push("");
|
|
3295
|
+
if (report.summary.ai_assessment) {
|
|
3296
|
+
lines.push(`> ${report.summary.ai_assessment}`);
|
|
3297
|
+
lines.push("");
|
|
3298
|
+
}
|
|
3299
|
+
if (report.features_detected.length > 0) {
|
|
3300
|
+
lines.push("## Detected Features");
|
|
3301
|
+
lines.push("");
|
|
3302
|
+
for (const feature of report.features_detected) {
|
|
3303
|
+
lines.push(`- ${feature}`);
|
|
3304
|
+
}
|
|
3305
|
+
lines.push("");
|
|
3306
|
+
}
|
|
3307
|
+
lines.push("## Findings");
|
|
3308
|
+
lines.push("");
|
|
3309
|
+
if (report.findings.length === 0) {
|
|
3310
|
+
lines.push("No significant issues found. Your app looks ready for review!");
|
|
3311
|
+
lines.push("");
|
|
3312
|
+
return lines.join("\n");
|
|
3313
|
+
}
|
|
3314
|
+
for (const finding of report.findings) {
|
|
3315
|
+
const riskEmoji = RISK_ICONS[finding.risk_level];
|
|
3316
|
+
lines.push(`### ${riskEmoji} ${finding.title}`);
|
|
3317
|
+
lines.push("");
|
|
3318
|
+
lines.push(`**Guideline:** ${finding.guideline_number} \u2014 ${finding.guideline_name}`);
|
|
3319
|
+
lines.push(`**Risk Level:** ${finding.risk_level}`);
|
|
3320
|
+
lines.push(`**Category:** ${finding.category}`);
|
|
3321
|
+
lines.push("");
|
|
3322
|
+
lines.push(finding.description);
|
|
3323
|
+
lines.push("");
|
|
3324
|
+
lines.push(`**User Impact:** ${finding.user_impact}`);
|
|
3325
|
+
lines.push("");
|
|
3326
|
+
if (finding.file) {
|
|
3327
|
+
lines.push(`**File:** \`${finding.file}\``);
|
|
3328
|
+
lines.push("");
|
|
3329
|
+
}
|
|
3330
|
+
lines.push(`**Recommendation:** ${finding.recommendation}`);
|
|
3331
|
+
lines.push("");
|
|
3332
|
+
lines.push("---");
|
|
3333
|
+
lines.push("");
|
|
3334
|
+
}
|
|
3335
|
+
lines.push("*Powered by Forvibe CLI \u2014 forvibe.app*");
|
|
3336
|
+
lines.push("");
|
|
3337
|
+
return lines.join("\n");
|
|
3338
|
+
}
|
|
3339
|
+
|
|
3340
|
+
// src/commands/review.ts
|
|
3341
|
+
function askQuestion2(question) {
|
|
3342
|
+
const rl = createInterface2({
|
|
3343
|
+
input: process.stdin,
|
|
3344
|
+
output: process.stdout
|
|
3345
|
+
});
|
|
3346
|
+
return new Promise((resolve) => {
|
|
3347
|
+
rl.question(question, (answer) => {
|
|
3348
|
+
rl.close();
|
|
3349
|
+
resolve(answer.trim());
|
|
3350
|
+
});
|
|
3351
|
+
});
|
|
3352
|
+
}
|
|
3353
|
+
async function reviewCommand(options) {
|
|
3354
|
+
const rootDir = options.dir || process.cwd();
|
|
3355
|
+
const deep = options.deep ?? false;
|
|
3356
|
+
const format = options.format || "terminal";
|
|
3357
|
+
console.log();
|
|
3358
|
+
console.log(
|
|
3359
|
+
chalk3.bold(" Forvibe CLI") + chalk3.gray(" \u2014 App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "")
|
|
3360
|
+
);
|
|
3361
|
+
console.log();
|
|
3362
|
+
const availableProviders = getAvailableProviders(deep);
|
|
3363
|
+
if (availableProviders.length === 0) {
|
|
3364
|
+
console.log(chalk3.red(" \u2717 No AI API key found. Set one of the following:\n"));
|
|
3365
|
+
console.log(
|
|
3366
|
+
chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
|
|
3367
|
+
);
|
|
3368
|
+
console.log(
|
|
3369
|
+
chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
|
|
3370
|
+
);
|
|
3371
|
+
console.log(
|
|
3372
|
+
chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
|
|
3373
|
+
);
|
|
3374
|
+
console.log();
|
|
3375
|
+
console.log(
|
|
3376
|
+
chalk3.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n")
|
|
3377
|
+
);
|
|
3378
|
+
process.exit(1);
|
|
3379
|
+
}
|
|
3380
|
+
let provider;
|
|
3381
|
+
if (availableProviders.length === 1) {
|
|
3382
|
+
provider = availableProviders[0].create();
|
|
3383
|
+
} else {
|
|
3384
|
+
console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
|
|
3385
|
+
availableProviders.forEach((p, i) => {
|
|
3386
|
+
const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
|
|
3387
|
+
console.log(` ${label}`);
|
|
3388
|
+
});
|
|
3389
|
+
console.log();
|
|
3390
|
+
const answer = await askQuestion2(
|
|
3391
|
+
chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
|
|
3392
|
+
);
|
|
3393
|
+
const index = parseInt(answer, 10) - 1;
|
|
3394
|
+
if (isNaN(index) || index < 0 || index >= availableProviders.length) {
|
|
3395
|
+
const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
|
|
3396
|
+
provider = recommended.create();
|
|
3397
|
+
console.log(chalk3.gray(` Using ${recommended.name} (default)
|
|
3398
|
+
`));
|
|
3399
|
+
} else {
|
|
3400
|
+
provider = availableProviders[index].create();
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
|
|
3404
|
+
console.log();
|
|
3405
|
+
const techSpinner = ora2({
|
|
3406
|
+
text: "Detecting tech stack...",
|
|
3407
|
+
prefixText: " "
|
|
3408
|
+
}).start();
|
|
3409
|
+
const techStack = detectTechStack(rootDir);
|
|
3410
|
+
if (techStack.stack === "unknown") {
|
|
3411
|
+
techSpinner.fail(
|
|
3412
|
+
chalk3.red(
|
|
3413
|
+
"No supported project found. Supported: Swift, Flutter, React Native, Kotlin, Capacitor, .NET MAUI"
|
|
3414
|
+
)
|
|
3415
|
+
);
|
|
3416
|
+
process.exit(1);
|
|
3417
|
+
}
|
|
3418
|
+
techSpinner.succeed(
|
|
3419
|
+
`Tech stack: ${chalk3.bold(techStack.label)} ${chalk3.gray(`(${techStack.platforms.join(", ")})`)}`
|
|
3420
|
+
);
|
|
3421
|
+
const configSpinner = ora2({
|
|
3422
|
+
text: "Reading project configuration...",
|
|
3423
|
+
prefixText: " "
|
|
3424
|
+
}).start();
|
|
3425
|
+
const config = parseConfig(rootDir, techStack.stack);
|
|
3426
|
+
const appName = config.app_name || "Unknown App";
|
|
3427
|
+
const bundleId = config.bundle_id || "";
|
|
3428
|
+
configSpinner.succeed(
|
|
3429
|
+
`Config: ${chalk3.white(appName)} ${bundleId ? chalk3.gray(`(${bundleId})`) : ""}`
|
|
3430
|
+
);
|
|
3431
|
+
const sdkSpinner = ora2({
|
|
3432
|
+
text: "Scanning dependencies...",
|
|
3433
|
+
prefixText: " "
|
|
3434
|
+
}).start();
|
|
3435
|
+
const sdkScan = scanSDKs(rootDir, techStack.stack);
|
|
3436
|
+
sdkSpinner.succeed(
|
|
3437
|
+
`Dependencies: ${chalk3.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
|
|
3438
|
+
);
|
|
3439
|
+
const sourceSpinner = ora2({
|
|
3440
|
+
text: "Reading source code...",
|
|
3441
|
+
prefixText: " "
|
|
3442
|
+
}).start();
|
|
3443
|
+
const maxChars = deep ? 4e5 : 15e4;
|
|
3444
|
+
const sourceCode = readSourceCode(rootDir, techStack.stack, maxChars);
|
|
3445
|
+
const projectTree = generateProjectTree(rootDir);
|
|
3446
|
+
const sourceLines = sourceCode.split("\n").length;
|
|
3447
|
+
sourceSpinner.succeed(
|
|
3448
|
+
`Source code: ${chalk3.bold(String(sourceLines))} lines analyzed`
|
|
3449
|
+
);
|
|
3450
|
+
console.log();
|
|
3451
|
+
const reviewSpinner = ora2({
|
|
3452
|
+
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)...`,
|
|
3453
|
+
prefixText: " "
|
|
3454
|
+
}).start();
|
|
3455
|
+
let report;
|
|
3456
|
+
try {
|
|
3457
|
+
const enrichedSource = buildEnrichedContext(
|
|
3458
|
+
sourceCode,
|
|
3459
|
+
projectTree,
|
|
3460
|
+
sdkScan,
|
|
3461
|
+
config
|
|
3462
|
+
);
|
|
3463
|
+
report = await runCodeReview(
|
|
3464
|
+
provider,
|
|
3465
|
+
techStack.label,
|
|
3466
|
+
appName,
|
|
3467
|
+
bundleId,
|
|
3468
|
+
enrichedSource,
|
|
3469
|
+
sourceLines,
|
|
3470
|
+
deep
|
|
3471
|
+
);
|
|
3472
|
+
reviewSpinner.succeed(
|
|
3473
|
+
chalk3.green(
|
|
3474
|
+
`Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
|
|
3475
|
+
)
|
|
3476
|
+
);
|
|
3477
|
+
} catch (error) {
|
|
3478
|
+
reviewSpinner.fail(
|
|
3479
|
+
chalk3.red(
|
|
3480
|
+
`Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3481
|
+
)
|
|
3482
|
+
);
|
|
3483
|
+
process.exit(1);
|
|
3484
|
+
}
|
|
3485
|
+
switch (format) {
|
|
3486
|
+
case "json":
|
|
3487
|
+
console.log(formatJSON(report));
|
|
3488
|
+
break;
|
|
3489
|
+
case "markdown":
|
|
3490
|
+
console.log(formatMarkdown(report));
|
|
3491
|
+
break;
|
|
3492
|
+
case "terminal":
|
|
3493
|
+
default:
|
|
3494
|
+
console.log(formatTerminal(report));
|
|
3495
|
+
break;
|
|
3496
|
+
}
|
|
3497
|
+
if (options.send) {
|
|
3498
|
+
console.log(
|
|
3499
|
+
chalk3.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")
|
|
3500
|
+
);
|
|
3501
|
+
console.log();
|
|
3502
|
+
const otcCode = await askQuestion2(
|
|
3503
|
+
chalk3.cyan(" \u{1F517} Enter your Forvibe connection code: ")
|
|
3504
|
+
);
|
|
3505
|
+
if (!otcCode || otcCode.length < 6) {
|
|
3506
|
+
console.log(
|
|
3507
|
+
chalk3.red(
|
|
3508
|
+
"\n \u2717 Invalid code. Please enter the 6-character code from forvibe.app\n"
|
|
3509
|
+
)
|
|
3510
|
+
);
|
|
3511
|
+
process.exit(1);
|
|
3512
|
+
}
|
|
3513
|
+
const connectSpinner = ora2({
|
|
3514
|
+
text: "Connecting to Forvibe...",
|
|
3515
|
+
prefixText: " "
|
|
3516
|
+
}).start();
|
|
3517
|
+
const client = new ForvibeClient(options.apiUrl);
|
|
3518
|
+
try {
|
|
3519
|
+
await client.validateOTC(otcCode);
|
|
3520
|
+
connectSpinner.succeed(chalk3.green("Connected to Forvibe!"));
|
|
3521
|
+
} catch (error) {
|
|
3522
|
+
connectSpinner.fail(
|
|
3523
|
+
chalk3.red(error instanceof Error ? error.message : "Connection failed")
|
|
3524
|
+
);
|
|
3525
|
+
process.exit(1);
|
|
3526
|
+
}
|
|
3527
|
+
const sendSpinner = ora2({
|
|
3528
|
+
text: "Sending review results to Forvibe...",
|
|
3529
|
+
prefixText: " "
|
|
3530
|
+
}).start();
|
|
3531
|
+
try {
|
|
3532
|
+
const result = await client.submitCodebaseReview(report);
|
|
3533
|
+
sendSpinner.succeed(chalk3.green("Review results sent!"));
|
|
3534
|
+
console.log();
|
|
3535
|
+
console.log(
|
|
3536
|
+
chalk3.bold(" \u{1F310} View combined review at: ") + chalk3.cyan.underline(result.web_url)
|
|
3537
|
+
);
|
|
3538
|
+
console.log();
|
|
3539
|
+
} catch (error) {
|
|
3540
|
+
sendSpinner.fail(
|
|
3541
|
+
chalk3.red(
|
|
3542
|
+
`Failed to send: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
3543
|
+
)
|
|
3544
|
+
);
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
function buildEnrichedContext(sourceCode, projectTree, sdkScan, config) {
|
|
3549
|
+
const sections = [];
|
|
3550
|
+
sections.push(`## Project Structure
|
|
3551
|
+
\`\`\`
|
|
3552
|
+
${projectTree}
|
|
3553
|
+
\`\`\``);
|
|
3554
|
+
if (sdkScan.detected_sdks.length > 0) {
|
|
3555
|
+
const sdkList = sdkScan.detected_sdks.map((sdk) => `- ${sdk.name} (${sdk.category})${sdk.collects.length > 0 ? ` \u2014 collects: ${sdk.collects.join(", ")}` : ""}`).join("\n");
|
|
3556
|
+
sections.push(`## Detected SDKs
|
|
3557
|
+
${sdkList}`);
|
|
3558
|
+
}
|
|
3559
|
+
sections.push(`## Source Code
|
|
3560
|
+
|
|
3561
|
+
${sourceCode}`);
|
|
3562
|
+
return sections.join("\n\n");
|
|
3563
|
+
}
|
|
3564
|
+
|
|
2313
3565
|
// src/index.ts
|
|
2314
3566
|
var program = new Command();
|
|
2315
3567
|
program.name("forvibe").description("Forvibe CLI \u2014 AI-powered App Store automation").version("0.1.0");
|
|
@@ -2320,4 +3572,11 @@ program.command("analyze", { isDefault: true }).description(
|
|
|
2320
3572
|
"Forvibe API URL (for development)",
|
|
2321
3573
|
void 0
|
|
2322
3574
|
).action(analyzeCommand);
|
|
3575
|
+
program.command("review").description(
|
|
3576
|
+
"Simulate Apple's App Store review process on your project"
|
|
3577
|
+
).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(
|
|
3578
|
+
"--api-url <url>",
|
|
3579
|
+
"Forvibe API URL (for development)",
|
|
3580
|
+
void 0
|
|
3581
|
+
).action(reviewCommand);
|
|
2323
3582
|
program.parse();
|