@forvibe/cli 1.0.6 → 1.0.8
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 +954 -19
- 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
|
|
@@ -1731,8 +1731,8 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
1731
1731
|
const priorityPatterns = getPriorityPatternsForStack(techStack);
|
|
1732
1732
|
const allFiles = scanDirectory(rootDir, {
|
|
1733
1733
|
extensions,
|
|
1734
|
-
maxDepth:
|
|
1735
|
-
maxFiles:
|
|
1734
|
+
maxDepth: 8,
|
|
1735
|
+
maxFiles: 500
|
|
1736
1736
|
});
|
|
1737
1737
|
const sorted = sortByPriority(allFiles, priorityPatterns);
|
|
1738
1738
|
const parts = [];
|
|
@@ -1743,7 +1743,7 @@ function readSourceCode(rootDir, techStack, maxTotalChars = 5e4) {
|
|
|
1743
1743
|
if (!content || content.trim().length < 20) continue;
|
|
1744
1744
|
if (isTestFile(file)) continue;
|
|
1745
1745
|
if (isGeneratedFile(file, content)) continue;
|
|
1746
|
-
const truncated = content.substring(0,
|
|
1746
|
+
const truncated = content.substring(0, 15e3);
|
|
1747
1747
|
parts.push(`--- ${file} ---
|
|
1748
1748
|
${truncated}`);
|
|
1749
1749
|
totalChars += truncated.length;
|
|
@@ -1869,9 +1869,9 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
|
|
|
1869
1869
|
}
|
|
1870
1870
|
}
|
|
1871
1871
|
}
|
|
1872
|
-
function isDir(
|
|
1872
|
+
function isDir(path2) {
|
|
1873
1873
|
try {
|
|
1874
|
-
return statSync4(
|
|
1874
|
+
return statSync4(path2).isDirectory();
|
|
1875
1875
|
} catch {
|
|
1876
1876
|
return false;
|
|
1877
1877
|
}
|
|
@@ -2102,30 +2102,30 @@ function scanAppAssets(rootDir, techStack) {
|
|
|
2102
2102
|
collectImagesRecursive(rootDir, dir, "screenshot", assets, MAX_SCREENSHOTS, 3, 0, minSize);
|
|
2103
2103
|
}
|
|
2104
2104
|
const splashPaths = getSplashPaths(rootDir, techStack);
|
|
2105
|
-
for (const
|
|
2105
|
+
for (const path2 of splashPaths) {
|
|
2106
2106
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
2107
2107
|
if (assets.some((a) => a.asset_type === "splash_screen")) break;
|
|
2108
|
-
const asset = tryReadImage(rootDir,
|
|
2108
|
+
const asset = tryReadImage(rootDir, path2, "splash_screen");
|
|
2109
2109
|
if (asset) {
|
|
2110
2110
|
assets.push(asset);
|
|
2111
2111
|
break;
|
|
2112
2112
|
}
|
|
2113
2113
|
}
|
|
2114
2114
|
const featurePaths = getFeatureGraphicPaths(rootDir, techStack);
|
|
2115
|
-
for (const
|
|
2115
|
+
for (const path2 of featurePaths) {
|
|
2116
2116
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
2117
2117
|
if (assets.some((a) => a.asset_type === "feature_graphic")) break;
|
|
2118
|
-
const asset = tryReadImage(rootDir,
|
|
2118
|
+
const asset = tryReadImage(rootDir, path2, "feature_graphic");
|
|
2119
2119
|
if (asset) {
|
|
2120
2120
|
assets.push(asset);
|
|
2121
2121
|
break;
|
|
2122
2122
|
}
|
|
2123
2123
|
}
|
|
2124
2124
|
const promoPaths = getPromotionalPaths(rootDir, techStack);
|
|
2125
|
-
for (const
|
|
2125
|
+
for (const path2 of promoPaths) {
|
|
2126
2126
|
if (assets.length >= MAX_TOTAL_ASSETS) break;
|
|
2127
2127
|
if (assets.some((a) => a.asset_type === "promotional_image")) break;
|
|
2128
|
-
const asset = tryReadImage(rootDir,
|
|
2128
|
+
const asset = tryReadImage(rootDir, path2, "promotional_image");
|
|
2129
2129
|
if (asset) {
|
|
2130
2130
|
assets.push(asset);
|
|
2131
2131
|
break;
|
|
@@ -2237,17 +2237,56 @@ var ForvibeClient = class {
|
|
|
2237
2237
|
}
|
|
2238
2238
|
return await response.json();
|
|
2239
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
|
+
}
|
|
2240
2278
|
};
|
|
2241
2279
|
|
|
2242
2280
|
// src/ai/providers.ts
|
|
2243
2281
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
2244
|
-
function createGeminiProvider(apiKey) {
|
|
2282
|
+
function createGeminiProvider(apiKey, deep) {
|
|
2283
|
+
const modelId = deep ? "gemini-3.1-pro-preview" : "gemini-3-flash-preview";
|
|
2245
2284
|
return {
|
|
2246
|
-
name:
|
|
2285
|
+
name: `Gemini (${deep ? "3.1 Pro" : "3 Flash"})`,
|
|
2247
2286
|
async generateJSON(systemPrompt, userPrompt, temperature) {
|
|
2248
2287
|
const genAI = new GoogleGenerativeAI(apiKey);
|
|
2249
2288
|
const model = genAI.getGenerativeModel({
|
|
2250
|
-
model:
|
|
2289
|
+
model: modelId,
|
|
2251
2290
|
generationConfig: {
|
|
2252
2291
|
temperature,
|
|
2253
2292
|
responseMimeType: "application/json"
|
|
@@ -2336,7 +2375,7 @@ function createClaudeProvider(apiKey) {
|
|
|
2336
2375
|
}
|
|
2337
2376
|
};
|
|
2338
2377
|
}
|
|
2339
|
-
function getAvailableProviders() {
|
|
2378
|
+
function getAvailableProviders(deep = false) {
|
|
2340
2379
|
const providers = [];
|
|
2341
2380
|
const anthropicKey = process.env.ANTHROPIC_API_KEY;
|
|
2342
2381
|
if (anthropicKey) {
|
|
@@ -2360,9 +2399,9 @@ function getAvailableProviders() {
|
|
|
2360
2399
|
if (geminiKey) {
|
|
2361
2400
|
providers.push({
|
|
2362
2401
|
id: "gemini",
|
|
2363
|
-
name: "Gemini",
|
|
2402
|
+
name: deep ? "Gemini 3.1 Pro" : "Gemini 3 Flash",
|
|
2364
2403
|
recommended: false,
|
|
2365
|
-
create: () => createGeminiProvider(geminiKey)
|
|
2404
|
+
create: () => createGeminiProvider(geminiKey, deep)
|
|
2366
2405
|
});
|
|
2367
2406
|
}
|
|
2368
2407
|
return providers;
|
|
@@ -2612,6 +2651,53 @@ async function analyzeCommand(options) {
|
|
|
2612
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"));
|
|
2613
2652
|
}
|
|
2614
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();
|
|
2615
2701
|
const sendSpinner = ora({
|
|
2616
2702
|
text: "Sending report to Forvibe...",
|
|
2617
2703
|
prefixText: " "
|
|
@@ -2634,6 +2720,848 @@ async function analyzeCommand(options) {
|
|
|
2634
2720
|
}
|
|
2635
2721
|
}
|
|
2636
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
|
+
|
|
2637
3565
|
// src/index.ts
|
|
2638
3566
|
var program = new Command();
|
|
2639
3567
|
program.name("forvibe").description("Forvibe CLI \u2014 AI-powered App Store automation").version("0.1.0");
|
|
@@ -2644,4 +3572,11 @@ program.command("analyze", { isDefault: true }).description(
|
|
|
2644
3572
|
"Forvibe API URL (for development)",
|
|
2645
3573
|
void 0
|
|
2646
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);
|
|
2647
3582
|
program.parse();
|