@forvibe/cli 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,11 +4,8 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/commands/analyze.ts
7
- import { createInterface } from "readline";
8
- import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
9
- import { basename as basename3, extname as extname4, relative as relative5 } from "path";
10
- import chalk from "chalk";
11
- import ora from "ora";
7
+ import chalk3 from "chalk";
8
+ import ora3 from "ora";
12
9
 
13
10
  // src/analyzers/tech-detector.ts
14
11
  import { existsSync as existsSync2, readdirSync as readdirSync2 } from "fs";
@@ -129,11 +126,12 @@ function findFiles(rootDir, extensions, maxDepth = 5) {
129
126
  (f) => join(rootDir, f)
130
127
  );
131
128
  }
132
- function readFileSafe(filePath) {
129
+ var DEFAULT_MAX_READ_BYTES = 1024 * 1024;
130
+ function readFileSafe(filePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
133
131
  try {
134
132
  if (!existsSync(filePath)) return null;
135
133
  const stat = statSync(filePath);
136
- if (stat.size > 1024 * 1024) return null;
134
+ if (stat.size > maxBytes) return null;
137
135
  return readFileSync(filePath, "utf-8");
138
136
  } catch {
139
137
  return null;
@@ -1745,11 +1743,11 @@ function extractExpoColors(rootDir) {
1745
1743
  const hexColors = [];
1746
1744
  let primary = null;
1747
1745
  let secondary = null;
1748
- const HEX = /^#[0-9a-fA-F]{6}$/;
1746
+ const HEX2 = /^#[0-9a-fA-F]{6}$/;
1749
1747
  const normalize = (raw) => {
1750
1748
  if (!raw) return null;
1751
1749
  const v = raw.trim();
1752
- if (HEX.test(v)) return v.toLowerCase();
1750
+ if (HEX2.test(v)) return v.toLowerCase();
1753
1751
  if (/^#[0-9a-fA-F]{8}$/.test(v)) return `#${v.slice(3).toLowerCase()}`;
1754
1752
  return null;
1755
1753
  };
@@ -1803,11 +1801,11 @@ function extractMauiColors(rootDir) {
1803
1801
  const name = match[1].toLowerCase();
1804
1802
  let hex = match[2];
1805
1803
  if (hex.length === 8) hex = hex.substring(2);
1806
- const color = `#${hex.toLowerCase()}`;
1804
+ const color2 = `#${hex.toLowerCase()}`;
1807
1805
  if (name.includes("primary") && !name.includes("dark") && !name.includes("light")) {
1808
- hexColors.unshift(color);
1806
+ hexColors.unshift(color2);
1809
1807
  } else {
1810
- hexColors.push(color);
1808
+ hexColors.push(color2);
1811
1809
  }
1812
1810
  }
1813
1811
  const brushMatches = content.matchAll(
@@ -1967,8 +1965,8 @@ function extractSwiftColors(rootDir) {
1967
1965
  const entries = readdirSync5(catalog);
1968
1966
  for (const entry of entries) {
1969
1967
  if (entry.endsWith(".colorset") && entry !== "AccentColor.colorset") {
1970
- const color = parseColorSet(join5(catalog, entry));
1971
- if (color) hexColors.push(color);
1968
+ const color2 = parseColorSet(join5(catalog, entry));
1969
+ if (color2) hexColors.push(color2);
1972
1970
  }
1973
1971
  }
1974
1972
  } catch {
@@ -1997,9 +1995,9 @@ function extractSwiftColors(rootDir) {
1997
1995
  const namedColorMatches = content.matchAll(/Color\s*\(\s*"([^"]+)"\s*\)/g);
1998
1996
  for (const match of namedColorMatches) {
1999
1997
  for (const catalog of assetsCatalogs) {
2000
- const color = parseColorSetInCatalog(catalog, match[1]);
2001
- if (color) {
2002
- hexColors.push(color);
1998
+ const color2 = parseColorSetInCatalog(catalog, match[1]);
1999
+ if (color2) {
2000
+ hexColors.push(color2);
2003
2001
  break;
2004
2002
  }
2005
2003
  }
@@ -2849,7 +2847,20 @@ function getPriorityPatternsForStack(techStack) {
2849
2847
  ];
2850
2848
  switch (techStack) {
2851
2849
  case "flutter":
2852
- return [...common, "widget", "screen", "page", "bloc", "provider", "controller"];
2850
+ return [
2851
+ ...common,
2852
+ "widget",
2853
+ "screen",
2854
+ "page",
2855
+ "bloc",
2856
+ "provider",
2857
+ "controller",
2858
+ "view",
2859
+ "viewmodel",
2860
+ "settings",
2861
+ "auth",
2862
+ "onboarding"
2863
+ ];
2853
2864
  case "swift":
2854
2865
  return [...common, "view", "controller", "manager", "delegate", "contentview"];
2855
2866
  case "kotlin":
@@ -2885,21 +2896,41 @@ function getPriorityPatternsForStack(techStack) {
2885
2896
  return common;
2886
2897
  }
2887
2898
  }
2899
+ var MAX_PER_DIR = 4;
2900
+ function scoreFile(rel, patterns) {
2901
+ const lower = rel.toLowerCase();
2902
+ const slash = Math.max(lower.lastIndexOf("/"), lower.lastIndexOf("\\"));
2903
+ const base = slash >= 0 ? lower.slice(slash + 1) : lower;
2904
+ const dir = slash >= 0 ? lower.slice(0, slash) : "";
2905
+ let score = 0;
2906
+ for (const p of patterns) {
2907
+ if (base.includes(p)) score += 3;
2908
+ else if (dir.includes(p)) score += 1;
2909
+ }
2910
+ return score - Math.min(rel.split(/[/\\]/).length - 1, 6);
2911
+ }
2888
2912
  function sortByPriority(files, patterns) {
2889
- return [...files].sort((a, b) => {
2890
- const aLower = a.toLowerCase();
2891
- const bLower = b.toLowerCase();
2892
- const aScore = patterns.reduce(
2893
- (score, pattern) => aLower.includes(pattern) ? score + 1 : score,
2894
- 0
2895
- );
2896
- const bScore = patterns.reduce(
2897
- (score, pattern) => bLower.includes(pattern) ? score + 1 : score,
2898
- 0
2899
- );
2900
- if (aScore !== bScore) return bScore - aScore;
2901
- return a.length - b.length;
2913
+ const ranked = [...files].sort((a, b) => {
2914
+ const d = scoreFile(b, patterns) - scoreFile(a, patterns);
2915
+ if (d !== 0) return d;
2916
+ if (a.length !== b.length) return a.length - b.length;
2917
+ return a.localeCompare(b);
2902
2918
  });
2919
+ const perDir = /* @__PURE__ */ new Map();
2920
+ const primary = [];
2921
+ const overflow = [];
2922
+ for (const file of ranked) {
2923
+ const slash = Math.max(file.lastIndexOf("/"), file.lastIndexOf("\\"));
2924
+ const dir = slash >= 0 ? file.slice(0, slash) : "";
2925
+ const used = perDir.get(dir) ?? 0;
2926
+ if (used < MAX_PER_DIR) {
2927
+ perDir.set(dir, used + 1);
2928
+ primary.push(file);
2929
+ } else {
2930
+ overflow.push(file);
2931
+ }
2932
+ }
2933
+ return [...primary, ...overflow];
2903
2934
  }
2904
2935
  function isTestFile(file) {
2905
2936
  const lower = file.toLowerCase();
@@ -2978,12 +3009,57 @@ function generateProjectTree(rootDir, maxDepth = 5, maxEntries = 300) {
2978
3009
  // src/analyzers/asset-scanner.ts
2979
3010
  import { readFileSync as readFileSync4, existsSync as existsSync5, readdirSync as readdirSync8, statSync as statSync5 } from "fs";
2980
3011
  import { join as join8, extname as extname3, relative as relative4, basename as basename2 } from "path";
3012
+
3013
+ // src/utils/image-size.ts
3014
+ function imageSize(buffer) {
3015
+ try {
3016
+ if (buffer.length >= 24 && buffer.readUInt32BE(0) === 2303741511 && buffer.toString("ascii", 12, 16) === "IHDR") {
3017
+ return valid(buffer.readUInt32BE(16), buffer.readUInt32BE(20));
3018
+ }
3019
+ if (buffer.length >= 4 && buffer[0] === 255 && buffer[1] === 216) {
3020
+ let offset = 2;
3021
+ while (offset + 9 < buffer.length) {
3022
+ if (buffer[offset] !== 255) return null;
3023
+ const marker = buffer[offset + 1];
3024
+ const length = buffer.readUInt16BE(offset + 2);
3025
+ const isFrame = marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204;
3026
+ if (isFrame) return valid(buffer.readUInt16BE(offset + 7), buffer.readUInt16BE(offset + 5));
3027
+ offset += 2 + length;
3028
+ }
3029
+ return null;
3030
+ }
3031
+ if (buffer.length >= 30 && buffer.toString("ascii", 0, 4) === "RIFF" && buffer.toString("ascii", 8, 12) === "WEBP") {
3032
+ const chunk = buffer.toString("ascii", 12, 16);
3033
+ if (chunk === "VP8X") return valid(1 + buffer.readUIntLE(24, 3), 1 + buffer.readUIntLE(27, 3));
3034
+ if (chunk === "VP8 ") return valid(buffer.readUInt16LE(26) & 16383, buffer.readUInt16LE(28) & 16383);
3035
+ if (chunk === "VP8L") {
3036
+ const bits = buffer.readUInt32LE(21);
3037
+ return valid((bits & 16383) + 1, (bits >> 14 & 16383) + 1);
3038
+ }
3039
+ }
3040
+ } catch {
3041
+ return null;
3042
+ }
3043
+ return null;
3044
+ }
3045
+ function valid(width, height) {
3046
+ return width > 0 && height > 0 ? { width, height } : null;
3047
+ }
3048
+
3049
+ // src/analyzers/asset-scanner.ts
2981
3050
  var MAX_ASSET_SIZE = 5 * 1024 * 1024;
2982
3051
  var MAX_TOTAL_ASSETS = 10;
2983
3052
  var MAX_SCREENSHOTS = 5;
2984
3053
  var MIN_ASSET_SIZE = 500;
2985
3054
  var MIN_SCREENSHOT_SIZE = 1e4;
2986
3055
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg", ".webp"]);
3056
+ var NOT_A_SCREEN = /(^|[-_. ])(icons?|logos?|splash|favicon|adaptive|foreground|background|banner|feature[-_ ]?graphic|promo)([-_. ]|$)/i;
3057
+ function looksLikeScreen(fileName, size) {
3058
+ if (NOT_A_SCREEN.test(fileName.replace(/\.[a-z0-9]+$/i, ""))) return false;
3059
+ if (!size) return true;
3060
+ const ratio = size.height / size.width;
3061
+ return ratio >= 1.25 && ratio <= 2.4;
3062
+ }
2987
3063
  function getMimeType(filePath) {
2988
3064
  const ext = extname3(filePath).toLowerCase();
2989
3065
  if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
@@ -3001,13 +3077,15 @@ function tryReadImage(rootDir, filePath, assetType, minSize = MIN_ASSET_SIZE) {
3001
3077
  if (stat.size < minSize) return null;
3002
3078
  if (stat.size > MAX_ASSET_SIZE) return null;
3003
3079
  const buffer = readFileSync4(filePath);
3080
+ const size = imageSize(buffer);
3081
+ if (assetType === "screenshot" && !looksLikeScreen(basename2(filePath), size)) return null;
3004
3082
  return {
3005
3083
  asset_type: assetType,
3006
3084
  file_name: basename2(filePath),
3007
3085
  mime_type: getMimeType(filePath),
3008
3086
  base64_data: buffer.toString("base64"),
3009
- width: null,
3010
- height: null,
3087
+ width: size?.width ?? null,
3088
+ height: size?.height ?? null,
3011
3089
  source_path: relative4(rootDir, filePath)
3012
3090
  };
3013
3091
  } catch {
@@ -3023,7 +3101,7 @@ function collectImagesRecursive(rootDir, dirPath, assetType, assets, maxCount, m
3023
3101
  if (assets.filter((a) => a.asset_type === assetType).length >= maxCount) return;
3024
3102
  if (assets.length >= MAX_TOTAL_ASSETS) return;
3025
3103
  const fullPath = join8(dirPath, entry);
3026
- if (isImageFile(entry)) {
3104
+ if (isImageFile(entry) && !assets.some((existing) => existing.source_path === relative4(rootDir, fullPath))) {
3027
3105
  const asset = tryReadImage(rootDir, fullPath, assetType, minSize);
3028
3106
  if (asset) assets.push(asset);
3029
3107
  }
@@ -3301,18 +3379,19 @@ var ForvibeClient = class {
3301
3379
  sessionToken = null;
3302
3380
  sessionId = null;
3303
3381
  storeConfig = null;
3382
+ flowType = "appstore";
3304
3383
  constructor(baseUrl) {
3305
3384
  this.baseUrl = (baseUrl || process.env.FORVIBE_API_URL || DEFAULT_API_URL).replace(/\/$/, "");
3306
3385
  }
3307
3386
  /**
3308
3387
  * Validate OTC code and get session token
3309
3388
  */
3310
- async validateOTC(code) {
3389
+ async validateOTC(code, expectedFlow) {
3311
3390
  const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "");
3312
3391
  const response = await fetch(`${this.baseUrl}/api/agent/validate-otc`, {
3313
3392
  method: "POST",
3314
3393
  headers: { "Content-Type": "application/json" },
3315
- body: JSON.stringify({ code: normalizedCode })
3394
+ body: JSON.stringify(expectedFlow ? { code: normalizedCode, flow: expectedFlow } : { code: normalizedCode })
3316
3395
  });
3317
3396
  if (!response.ok) {
3318
3397
  const error = await response.json().catch(() => ({ error: "Unknown error" }));
@@ -3325,14 +3404,40 @@ var ForvibeClient = class {
3325
3404
  if (response.status === 404) {
3326
3405
  throw new Error("Invalid connection code. Please check and try again.");
3327
3406
  }
3407
+ if (response.status === 409 && error.error) {
3408
+ throw new Error(error.error);
3409
+ }
3328
3410
  throw new Error(error.error || `Validation failed (${response.status})`);
3329
3411
  }
3330
3412
  const data = await response.json();
3331
3413
  this.sessionToken = data.session_token;
3332
3414
  this.sessionId = data.session_id;
3333
3415
  this.storeConfig = data.store_config || null;
3416
+ this.flowType = data.flow_type ?? "appstore";
3334
3417
  return data;
3335
3418
  }
3419
+ /**
3420
+ * Submit the Screenshot Studio brief (flow_type "screenshots")
3421
+ */
3422
+ async submitScreenshotBrief(brief) {
3423
+ if (!this.sessionToken) {
3424
+ throw new Error("Not connected. Please validate OTC code first.");
3425
+ }
3426
+ const response = await fetchWithAuth(`${this.baseUrl}/api/agent/screenshot-brief`, {
3427
+ method: "POST",
3428
+ headers: {
3429
+ "Content-Type": "application/json",
3430
+ Authorization: `Bearer ${this.sessionToken}`
3431
+ },
3432
+ body: JSON.stringify({ brief })
3433
+ });
3434
+ if (!response.ok) {
3435
+ const error = await response.json().catch(() => ({}));
3436
+ if (response.status === 401) throw new Error("Session expired. Please generate a new connection code in Screenshot Studio.");
3437
+ throw new Error(error.error || `Could not send the brief (${response.status})`);
3438
+ }
3439
+ return await response.json();
3440
+ }
3336
3441
  /**
3337
3442
  * Submit the CLI project report
3338
3443
  */
@@ -3469,16 +3574,326 @@ var ForvibeClient = class {
3469
3574
  }
3470
3575
  };
3471
3576
 
3577
+ // src/commands/upload-assets.ts
3578
+ import { readFileSync as readFileSync5, statSync as statSync6 } from "fs";
3579
+ import { basename as basename3, extname as extname4, relative as relative5 } from "path";
3580
+ function mimeForPath(p) {
3581
+ const ext = extname4(p).toLowerCase();
3582
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
3583
+ if (ext === ".webp") return "image/webp";
3584
+ return "image/png";
3585
+ }
3586
+ async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
3587
+ const pending = [];
3588
+ for (const asset of assets) {
3589
+ if (!asset.base64_data) continue;
3590
+ pending.push({
3591
+ asset,
3592
+ buffer: Buffer.from(asset.base64_data, "base64")
3593
+ });
3594
+ }
3595
+ if (appIconPath && extname4(appIconPath).toLowerCase() !== ".svg") {
3596
+ try {
3597
+ const stat = statSync6(appIconPath);
3598
+ if (stat.isFile() && stat.size <= 5 * 1024 * 1024) {
3599
+ const buffer = readFileSync5(appIconPath);
3600
+ pending.push({
3601
+ asset: {
3602
+ asset_type: "app_icon",
3603
+ file_name: basename3(appIconPath),
3604
+ mime_type: mimeForPath(appIconPath),
3605
+ width: null,
3606
+ height: null,
3607
+ source_path: relative5(rootDir, appIconPath)
3608
+ },
3609
+ buffer
3610
+ });
3611
+ }
3612
+ } catch {
3613
+ }
3614
+ }
3615
+ if (pending.length === 0) return [];
3616
+ const { uploads } = await client.getAssetUploadUrls(
3617
+ pending.map((p) => ({
3618
+ asset_type: p.asset.asset_type,
3619
+ file_name: p.asset.file_name,
3620
+ mime_type: p.asset.mime_type
3621
+ }))
3622
+ );
3623
+ if (uploads.length !== pending.length) {
3624
+ throw new Error(
3625
+ `Upload slot count mismatch: requested ${pending.length}, got ${uploads.length}`
3626
+ );
3627
+ }
3628
+ const concurrency = 3;
3629
+ for (let i = 0; i < pending.length; i += concurrency) {
3630
+ const chunk = pending.slice(i, i + concurrency);
3631
+ const slots = uploads.slice(i, i + concurrency);
3632
+ await Promise.all(
3633
+ chunk.map(
3634
+ (p, idx) => client.uploadAssetToSignedUrl(
3635
+ slots[idx].signed_url,
3636
+ p.buffer,
3637
+ p.asset.mime_type
3638
+ )
3639
+ )
3640
+ );
3641
+ }
3642
+ return pending.map((p, i) => ({
3643
+ asset_type: p.asset.asset_type,
3644
+ file_name: uploads[i].file_name,
3645
+ mime_type: p.asset.mime_type,
3646
+ width: p.asset.width,
3647
+ height: p.asset.height,
3648
+ source_path: p.asset.source_path,
3649
+ storage_bucket: uploads[i].bucket,
3650
+ storage_path: uploads[i].storage_path
3651
+ }));
3652
+ }
3653
+
3654
+ // src/commands/screenshots.ts
3655
+ import chalk2 from "chalk";
3656
+ import ora2 from "ora";
3657
+
3658
+ // src/engine/redactor.ts
3659
+ function buildSimpleRules() {
3660
+ return [
3661
+ {
3662
+ kind: "pem",
3663
+ regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
3664
+ },
3665
+ { kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{16,}/g },
3666
+ {
3667
+ kind: "openai",
3668
+ regex: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}/g
3669
+ },
3670
+ { kind: "stripe", regex: /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
3671
+ { kind: "google", regex: /AIza[0-9A-Za-z_-]{35}/g },
3672
+ { kind: "aws", regex: /AKIA[0-9A-Z]{16}/g },
3673
+ { kind: "github", regex: /gh[pousr]_[A-Za-z0-9]{20,}/g },
3674
+ { kind: "slack", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
3675
+ {
3676
+ kind: "jwt",
3677
+ regex: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
3678
+ }
3679
+ ];
3680
+ }
3681
+ function buildGenericRule() {
3682
+ return /(api[_-]?key|apikey|secret|token|passwd|password|client[_-]?secret)(\s*[:=]\s*)(["'])([^"'\n]{12,})(["'])/gi;
3683
+ }
3684
+ var ALREADY_REDACTED = /^\[REDACTED:[A-Za-z0-9_]+\]$/;
3685
+ function redactSecrets(source) {
3686
+ let text2 = source;
3687
+ let redactedCount = 0;
3688
+ for (const { kind, regex } of buildSimpleRules()) {
3689
+ text2 = text2.replace(regex, () => {
3690
+ redactedCount++;
3691
+ return `[REDACTED:${kind}]`;
3692
+ });
3693
+ }
3694
+ text2 = text2.replace(
3695
+ buildGenericRule(),
3696
+ (full, key, delim, openQuote, value, closeQuote) => {
3697
+ if (ALREADY_REDACTED.test(value)) return full;
3698
+ redactedCount++;
3699
+ return `${key}${delim}${openQuote}[REDACTED:generic]${closeQuote}`;
3700
+ }
3701
+ );
3702
+ return { text: text2, redactedCount };
3703
+ }
3704
+
3705
+ // src/ai/screenshot-brief-generator.ts
3706
+ var SYSTEM_PROMPT = `You are the creative director of a mobile app's App Store screenshots. You read the app's code to understand what users can actually do and how the product looks. Forvibe will plan the screenshot set from your brief: one opening "hook" page with artwork, then one page per feature showing the real app.
3707
+
3708
+ Return ONLY a JSON object:
3709
+ {
3710
+ "description": "One or two sentences: what the app does for its user.",
3711
+ "category": "App Store style category, e.g. Education, Health & Fitness, Productivity",
3712
+ "features": [
3713
+ { "name": "Short user-facing feature name (2-5 words)", "description": "The user benefit in one sentence", "priority": 0-100 }
3714
+ ],
3715
+ "brand_personality": ["3-5 adjectives, e.g. calm, playful, premium"],
3716
+ "visual_keywords": ["3-8 concrete visual cues from the UI, e.g. rounded cards, soft gradients, dense data tables"],
3717
+ "ui_colors": { "accent": "#RRGGBB", "background": "#RRGGBB", "surface": "#RRGGBB", "text_primary": "#RRGGBB", "text_secondary": "#RRGGBB" },
3718
+ "typography": { "style": "e.g. geometric sans", "heading_style": "e.g. bold, tight", "body_style": "e.g. regular, comfortable" },
3719
+ "style": { "theme": "light|dark|mixed", "radius_style": "sharp|small|medium|large|pill", "density": "compact|balanced|spacious", "shadow_style": "none|subtle|medium|strong", "characteristics": ["short phrases"] },
3720
+ "screenshot_style_idea": {
3721
+ "concept": "A short name for an app-specific campaign idea",
3722
+ "metaphor": "How the product's real behavior could become a visual",
3723
+ "recurringMotifs": ["2-4 motifs that could repeat across pages"],
3724
+ "backgroundTreatment": "How backgrounds should feel",
3725
+ "accentTreatment": "Where the brand accent should appear",
3726
+ "pageRhythm": "How the set should progress"
3727
+ }
3728
+ }
3729
+
3730
+ RULES
3731
+ - features: 3 to 7 real, user-facing capabilities you can see implemented in the code (screens, views, flows). Order by importance with priority. No settings, onboarding, login, or generic items like "Beautiful design". Never invent features.
3732
+ - Colors: only hex values found in the code or assets, or clearly implied by them. Omit a color you cannot ground.
3733
+ - Ground everything in the supplied evidence. Omit a field rather than guess.
3734
+ - Never include secrets, keys, credentials, personal data, or code in the output.`;
3735
+ var HEX = /^#[0-9a-fA-F]{6}$/;
3736
+ var THEMES = ["light", "dark", "mixed"];
3737
+ var RADII = ["sharp", "small", "medium", "large", "pill"];
3738
+ var DENSITIES = ["compact", "balanced", "spacious"];
3739
+ var SHADOWS = ["none", "subtle", "medium", "strong"];
3740
+ function text(value, max) {
3741
+ if (typeof value !== "string") return void 0;
3742
+ const cleaned = value.replace(/\s+/g, " ").trim();
3743
+ return cleaned ? cleaned.slice(0, max) : void 0;
3744
+ }
3745
+ function list(value, maxItems, maxLength) {
3746
+ if (!Array.isArray(value)) return void 0;
3747
+ const items = value.map((item) => text(item, maxLength)).filter((item) => Boolean(item)).slice(0, maxItems);
3748
+ return items.length > 0 ? items : void 0;
3749
+ }
3750
+ function color(value) {
3751
+ return typeof value === "string" && HEX.test(value.trim()) ? value.trim().toUpperCase() : void 0;
3752
+ }
3753
+ function oneOf(value, allowed) {
3754
+ return typeof value === "string" && allowed.includes(value) ? value : void 0;
3755
+ }
3756
+ function record(value) {
3757
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
3758
+ }
3759
+ function compact(value) {
3760
+ const entries = Object.entries(value).filter(([, item]) => item !== void 0);
3761
+ return entries.length > 0 ? Object.fromEntries(entries) : void 0;
3762
+ }
3763
+ function buildScreenshotBriefPrompt(input) {
3764
+ const readme = input.readmeContent ? redactSecrets(input.readmeContent).text.slice(0, 8e3) : null;
3765
+ const source = redactSecrets(input.sourceCode).text.slice(0, 4e4);
3766
+ return [
3767
+ `APP NAME: ${input.config.app_name ?? "unknown"}`,
3768
+ `PLATFORMS: ${input.techStack.platforms.join(", ")} (${input.techStack.label})`,
3769
+ `BRAND COLORS FOUND: ${[input.branding.primary_color, input.branding.secondary_color, ...input.branding.brand_colors].filter(Boolean).join(", ") || "none"}`,
3770
+ `FONTS FOUND: ${input.detectedFonts.join(", ") || "none (system fonts)"}`,
3771
+ `EXISTING SCREENSHOTS IN REPO: ${input.screenshotCount}`,
3772
+ "",
3773
+ readme ? `README:
3774
+ ${readme}` : "README: none",
3775
+ "",
3776
+ input.projectTree ? `PROJECT TREE:
3777
+ ${input.projectTree.slice(0, 6e3)}` : "",
3778
+ "",
3779
+ `SOURCE EXCERPTS (secrets redacted):
3780
+ ${source}`
3781
+ ].join("\n");
3782
+ }
3783
+ function parseJson(response) {
3784
+ try {
3785
+ return JSON.parse(response);
3786
+ } catch {
3787
+ const match = response.match(/\{[\s\S]*\}/);
3788
+ if (!match) throw new Error("The AI response did not contain JSON");
3789
+ return JSON.parse(match[0].replace(/,\s*([}\]])/g, "$1"));
3790
+ }
3791
+ }
3792
+ function structuredDirection(value) {
3793
+ const raw = record(value);
3794
+ const concept = text(raw.concept, 160);
3795
+ if (!concept) return void 0;
3796
+ return compact({
3797
+ concept,
3798
+ metaphor: text(raw.metaphor, 500),
3799
+ recurringMotifs: list(raw.recurringMotifs, 12, 120),
3800
+ backgroundTreatment: text(raw.backgroundTreatment, 500),
3801
+ accentTreatment: text(raw.accentTreatment, 500),
3802
+ pageRhythm: text(raw.pageRhythm, 500)
3803
+ });
3804
+ }
3805
+ function normalizeScreenshotBrief(raw, input) {
3806
+ const data = record(raw);
3807
+ const seen = /* @__PURE__ */ new Set();
3808
+ const features = (Array.isArray(data.features) ? data.features : []).map((item, index) => {
3809
+ const feature = record(item);
3810
+ const name = text(feature.name, 120);
3811
+ if (!name || seen.has(name.toLowerCase())) return null;
3812
+ seen.add(name.toLowerCase());
3813
+ const priority = typeof feature.priority === "number" && Number.isFinite(feature.priority) ? Math.max(0, Math.min(100, Math.round(feature.priority))) : Math.max(0, 100 - index * 10);
3814
+ return compact({ name, description: text(feature.description, 500), priority });
3815
+ }).filter((feature) => feature !== null).sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)).slice(0, 7);
3816
+ if (features.length < 3) {
3817
+ throw new Error(`The AI found ${features.length} feature${features.length === 1 ? "" : "s"}; Screenshot Studio needs at least 3. Add a README that lists what the app does, then run the CLI again.`);
3818
+ }
3819
+ const uiColors = record(data.ui_colors);
3820
+ const typography = record(data.typography);
3821
+ const style = record(data.style);
3822
+ const design = compact({
3823
+ brand_personality: list(data.brand_personality, 12, 60),
3824
+ visual_keywords: list(data.visual_keywords, 20, 60),
3825
+ ui_colors: compact({
3826
+ accent: color(uiColors.accent),
3827
+ background: color(uiColors.background),
3828
+ surface: color(uiColors.surface),
3829
+ text_primary: color(uiColors.text_primary),
3830
+ text_secondary: color(uiColors.text_secondary)
3831
+ }),
3832
+ typography: compact({
3833
+ style: text(typography.style, 120),
3834
+ heading_style: text(typography.heading_style, 160),
3835
+ body_style: text(typography.body_style, 160)
3836
+ }),
3837
+ style: compact({
3838
+ theme: oneOf(style.theme, THEMES),
3839
+ radius_style: oneOf(style.radius_style, RADII),
3840
+ density: oneOf(style.density, DENSITIES),
3841
+ shadow_style: oneOf(style.shadow_style, SHADOWS),
3842
+ characteristics: list(style.characteristics, 20, 80)
3843
+ })
3844
+ });
3845
+ const platforms = input.techStack.platforms.filter((platform) => platform === "ios" || platform === "android");
3846
+ const brandColors = [...new Set(input.branding.brand_colors.map(color).filter((value) => Boolean(value)))].slice(0, 8);
3847
+ return compact({
3848
+ app_name: text(input.config.app_name, 120) ?? "Untitled App",
3849
+ bundle_id: text(input.config.bundle_id, 200),
3850
+ description: text(data.description, 1200),
3851
+ category: text(data.category, 120),
3852
+ platforms: platforms.length > 0 ? platforms : void 0,
3853
+ tech_stack: text(input.techStack.stack, 60),
3854
+ features,
3855
+ screenshot_style_idea: structuredDirection(data.screenshot_style_idea),
3856
+ screenshot_design: design,
3857
+ primary_color: color(input.branding.primary_color),
3858
+ secondary_color: color(input.branding.secondary_color),
3859
+ brand_colors: brandColors.length > 0 ? brandColors : void 0,
3860
+ detected_fonts: input.detectedFonts.length > 0 ? input.detectedFonts.slice(0, 8) : void 0
3861
+ });
3862
+ }
3863
+ async function generateScreenshotBrief(input, provider) {
3864
+ const response = await provider.generateJSON(SYSTEM_PROMPT, buildScreenshotBriefPrompt(input), 0.4);
3865
+ return normalizeScreenshotBrief(parseJson(response.replace(/^```(?:json)?\s*|\s*```\s*$/g, "")), input);
3866
+ }
3867
+
3868
+ // src/utils/version.ts
3869
+ import { createRequire } from "module";
3870
+ var FALLBACK_VERSION = "0.0.0";
3871
+ function getCliVersion() {
3872
+ const require2 = createRequire(import.meta.url);
3873
+ for (const candidate of ["../package.json", "../../package.json"]) {
3874
+ try {
3875
+ const pkg = require2(candidate);
3876
+ if (pkg?.version && pkg.name === "@forvibe/cli") return pkg.version;
3877
+ } catch {
3878
+ }
3879
+ }
3880
+ return FALLBACK_VERSION;
3881
+ }
3882
+
3883
+ // src/commands/connect.ts
3884
+ import { createInterface } from "readline";
3885
+ import chalk from "chalk";
3886
+ import ora from "ora";
3887
+
3472
3888
  // src/ai/providers.ts
3473
3889
  import { GoogleGenerativeAI } from "@google/generative-ai";
3474
- function createGeminiProvider(apiKey, deep) {
3475
- const modelId = deep ? "gemini-3.1-pro-preview" : "gemini-3-flash-preview";
3890
+ function createGeminiProvider(apiKey) {
3476
3891
  return {
3477
- name: `Gemini (${deep ? "3.1 Pro" : "3 Flash"})`,
3892
+ name: "Gemini 3.8 Flash",
3478
3893
  async generateJSON(systemPrompt, userPrompt, temperature) {
3479
3894
  const genAI = new GoogleGenerativeAI(apiKey);
3480
3895
  const model = genAI.getGenerativeModel({
3481
- model: modelId,
3896
+ model: "gemini-3.8-flash",
3482
3897
  generationConfig: {
3483
3898
  temperature,
3484
3899
  responseMimeType: "application/json"
@@ -3494,8 +3909,8 @@ function createGeminiProvider(apiKey, deep) {
3494
3909
  }
3495
3910
  function createOpenAIProvider(apiKey) {
3496
3911
  return {
3497
- name: "OpenAI",
3498
- async generateJSON(systemPrompt, userPrompt, temperature) {
3912
+ name: "OpenAI (GPT-5.6 Luna)",
3913
+ async generateJSON(systemPrompt, userPrompt, _temperature) {
3499
3914
  const response = await fetch(
3500
3915
  "https://api.openai.com/v1/chat/completions",
3501
3916
  {
@@ -3505,13 +3920,13 @@ function createOpenAIProvider(apiKey) {
3505
3920
  Authorization: `Bearer ${apiKey}`
3506
3921
  },
3507
3922
  body: JSON.stringify({
3508
- model: "gpt-4o-mini",
3923
+ model: "gpt-5.6-luna",
3509
3924
  messages: [
3510
3925
  { role: "system", content: systemPrompt },
3511
3926
  { role: "user", content: userPrompt }
3512
3927
  ],
3513
3928
  response_format: { type: "json_object" },
3514
- temperature
3929
+ reasoning_effort: "low"
3515
3930
  })
3516
3931
  }
3517
3932
  );
@@ -3532,8 +3947,8 @@ function createOpenAIProvider(apiKey) {
3532
3947
  }
3533
3948
  function createClaudeProvider(apiKey) {
3534
3949
  return {
3535
- name: "Claude",
3536
- async generateJSON(systemPrompt, userPrompt, temperature) {
3950
+ name: "Claude (Sonnet 5)",
3951
+ async generateJSON(systemPrompt, userPrompt, _temperature) {
3537
3952
  const response = await fetch(
3538
3953
  "https://api.anthropic.com/v1/messages",
3539
3954
  {
@@ -3544,11 +3959,11 @@ function createClaudeProvider(apiKey) {
3544
3959
  "anthropic-version": "2023-06-01"
3545
3960
  },
3546
3961
  body: JSON.stringify({
3547
- model: "claude-sonnet-4-6",
3548
- max_tokens: 8192,
3962
+ model: "claude-sonnet-5",
3963
+ max_tokens: 16e3,
3549
3964
  system: systemPrompt,
3550
3965
  messages: [{ role: "user", content: userPrompt }],
3551
- temperature
3966
+ output_config: { effort: "medium" }
3552
3967
  })
3553
3968
  }
3554
3969
  );
@@ -3559,21 +3974,21 @@ function createClaudeProvider(apiKey) {
3559
3974
  );
3560
3975
  }
3561
3976
  const data = await response.json();
3562
- const text = data.content?.find((b) => b.type === "text")?.text;
3563
- if (!text) {
3977
+ const text2 = data.content?.find((b) => b.type === "text")?.text;
3978
+ if (!text2) {
3564
3979
  throw new Error("Empty response from Claude");
3565
3980
  }
3566
- return text;
3981
+ return text2;
3567
3982
  }
3568
3983
  };
3569
3984
  }
3570
- function getAvailableProviders(deep = false) {
3985
+ function getAvailableProviders() {
3571
3986
  const providers = [];
3572
3987
  const anthropicKey = process.env.ANTHROPIC_API_KEY;
3573
3988
  if (anthropicKey) {
3574
3989
  providers.push({
3575
3990
  id: "claude",
3576
- name: "Claude",
3991
+ name: "Claude (Sonnet 5)",
3577
3992
  recommended: true,
3578
3993
  create: () => createClaudeProvider(anthropicKey)
3579
3994
  });
@@ -3582,7 +3997,7 @@ function getAvailableProviders(deep = false) {
3582
3997
  if (openaiKey) {
3583
3998
  providers.push({
3584
3999
  id: "openai",
3585
- name: "OpenAI",
4000
+ name: "OpenAI (GPT-5.6 Luna)",
3586
4001
  recommended: false,
3587
4002
  create: () => createOpenAIProvider(openaiKey)
3588
4003
  });
@@ -3591,116 +4006,37 @@ function getAvailableProviders(deep = false) {
3591
4006
  if (geminiKey) {
3592
4007
  providers.push({
3593
4008
  id: "gemini",
3594
- name: deep ? "Gemini 3.1 Pro" : "Gemini 3 Flash",
4009
+ name: "Gemini 3.8 Flash",
3595
4010
  recommended: false,
3596
- create: () => createGeminiProvider(geminiKey, deep)
4011
+ create: () => createGeminiProvider(geminiKey)
3597
4012
  });
3598
4013
  }
3599
4014
  return providers;
3600
4015
  }
3601
4016
 
3602
- // src/commands/analyze.ts
3603
- function mimeForPath(p) {
3604
- const ext = extname4(p).toLowerCase();
3605
- if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
3606
- if (ext === ".webp") return "image/webp";
3607
- return "image/png";
3608
- }
3609
- async function uploadAssetsToStorage(client, rootDir, assets, appIconPath) {
3610
- const pending = [];
3611
- for (const asset of assets) {
3612
- if (!asset.base64_data) continue;
3613
- pending.push({
3614
- asset,
3615
- buffer: Buffer.from(asset.base64_data, "base64")
4017
+ // src/commands/connect.ts
4018
+ function askQuestion(question) {
4019
+ const rl = createInterface({
4020
+ input: process.stdin,
4021
+ output: process.stdout
4022
+ });
4023
+ return new Promise((resolve) => {
4024
+ rl.question(question, (answer) => {
4025
+ rl.close();
4026
+ resolve(answer.trim());
3616
4027
  });
3617
- }
3618
- if (appIconPath) {
3619
- try {
3620
- const stat = statSync6(appIconPath);
3621
- if (stat.isFile() && stat.size <= 5 * 1024 * 1024) {
3622
- const buffer = readFileSync5(appIconPath);
3623
- pending.push({
3624
- asset: {
3625
- asset_type: "app_icon",
3626
- file_name: basename3(appIconPath),
3627
- mime_type: mimeForPath(appIconPath),
3628
- width: null,
3629
- height: null,
3630
- source_path: relative5(rootDir, appIconPath)
3631
- },
3632
- buffer
3633
- });
3634
- }
3635
- } catch {
3636
- }
3637
- }
3638
- if (pending.length === 0) return [];
3639
- const { uploads } = await client.getAssetUploadUrls(
3640
- pending.map((p) => ({
3641
- asset_type: p.asset.asset_type,
3642
- file_name: p.asset.file_name,
3643
- mime_type: p.asset.mime_type
3644
- }))
3645
- );
3646
- if (uploads.length !== pending.length) {
3647
- throw new Error(
3648
- `Upload slot count mismatch: requested ${pending.length}, got ${uploads.length}`
3649
- );
3650
- }
3651
- const concurrency = 3;
3652
- for (let i = 0; i < pending.length; i += concurrency) {
3653
- const chunk = pending.slice(i, i + concurrency);
3654
- const slots = uploads.slice(i, i + concurrency);
3655
- await Promise.all(
3656
- chunk.map(
3657
- (p, idx) => client.uploadAssetToSignedUrl(
3658
- slots[idx].signed_url,
3659
- p.buffer,
3660
- p.asset.mime_type
3661
- )
3662
- )
3663
- );
3664
- }
3665
- return pending.map((p, i) => ({
3666
- asset_type: p.asset.asset_type,
3667
- file_name: uploads[i].file_name,
3668
- mime_type: p.asset.mime_type,
3669
- width: p.asset.width,
3670
- height: p.asset.height,
3671
- source_path: p.asset.source_path,
3672
- storage_bucket: uploads[i].bucket,
3673
- storage_path: uploads[i].storage_path
3674
- }));
3675
- }
3676
- function askQuestion(question) {
3677
- const rl = createInterface({
3678
- input: process.stdin,
3679
- output: process.stdout
3680
- });
3681
- return new Promise((resolve) => {
3682
- rl.question(question, (answer) => {
3683
- rl.close();
3684
- resolve(answer.trim());
3685
- });
3686
- });
3687
- }
3688
- async function analyzeCommand(options) {
3689
- const rootDir = options.dir || process.cwd();
3690
- console.log();
3691
- console.log(
3692
- chalk.bold(" Forvibe CLI") + chalk.gray(" \u2014 AI-powered App Store automation")
3693
- );
3694
- console.log();
3695
- const availableProviders = getAvailableProviders();
3696
- if (availableProviders.length === 0) {
3697
- console.log(chalk.red(" \u2717 No AI API key found. Set one of the following:\n"));
3698
- console.log(chalk.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk.gray(" https://console.anthropic.com/settings/keys") + chalk.green(" (recommended)"));
3699
- console.log(chalk.cyan(" export OPENAI_API_KEY=your-key") + chalk.gray(" https://platform.openai.com/api-keys"));
3700
- console.log(chalk.cyan(" export GEMINI_API_KEY=your-key") + chalk.gray(" https://aistudio.google.com/apikey"));
3701
- console.log();
3702
- console.log(chalk.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n"));
3703
- process.exit(1);
4028
+ });
4029
+ }
4030
+ async function chooseProvider() {
4031
+ const availableProviders = getAvailableProviders();
4032
+ if (availableProviders.length === 0) {
4033
+ console.log(chalk.red(" \u2717 No AI API key found. Set one of the following:\n"));
4034
+ console.log(chalk.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk.gray(" https://console.anthropic.com/settings/keys") + chalk.green(" (recommended)"));
4035
+ console.log(chalk.cyan(" export OPENAI_API_KEY=your-key") + chalk.gray(" https://platform.openai.com/api-keys"));
4036
+ console.log(chalk.cyan(" export GEMINI_API_KEY=your-key") + chalk.gray(" https://aistudio.google.com/apikey"));
4037
+ console.log();
4038
+ console.log(chalk.gray(" Your source code is analyzed locally \u2014 it never leaves your machine.\n"));
4039
+ process.exit(1);
3704
4040
  }
3705
4041
  let provider;
3706
4042
  if (availableProviders.length === 1) {
@@ -3724,6 +4060,9 @@ async function analyzeCommand(options) {
3724
4060
  }
3725
4061
  }
3726
4062
  console.log(chalk.gray(` AI Provider: ${provider.name} \u2713`));
4063
+ return provider;
4064
+ }
4065
+ async function connectWithCode(client, expectedFlow) {
3727
4066
  const otcCode = await askQuestion(
3728
4067
  chalk.cyan(" \u{1F517} Enter your Forvibe connection code: ")
3729
4068
  );
@@ -3737,9 +4076,8 @@ async function analyzeCommand(options) {
3737
4076
  text: "Connecting to Forvibe...",
3738
4077
  prefixText: " "
3739
4078
  }).start();
3740
- const client = new ForvibeClient(options.apiUrl);
3741
4079
  try {
3742
- await client.validateOTC(otcCode);
4080
+ await client.validateOTC(otcCode, expectedFlow);
3743
4081
  connectSpinner.succeed(chalk.green("Connected to Forvibe!"));
3744
4082
  } catch (error) {
3745
4083
  connectSpinner.fail(
@@ -3748,23 +4086,135 @@ async function analyzeCommand(options) {
3748
4086
  process.exit(1);
3749
4087
  }
3750
4088
  console.log();
3751
- const techSpinner = ora({
4089
+ }
4090
+
4091
+ // src/commands/screenshots.ts
4092
+ async function runScreenshotsFlow(options) {
4093
+ const { client, provider, rootDir } = options;
4094
+ console.log(chalk2.white(" Screenshot Studio: reading your app to plan store screenshots.\n"));
4095
+ const scan = ora2({ text: "Detecting tech stack...", prefixText: " " }).start();
4096
+ const techStack = detectTechStack(rootDir);
4097
+ if (techStack.stack === "unknown") {
4098
+ scan.fail(chalk2.red("No supported project found. Run the CLI in your app's folder (Swift, Flutter, React Native, Expo, Kotlin, Capacitor, .NET MAUI)."));
4099
+ process.exit(1);
4100
+ }
4101
+ scan.succeed(`Tech stack: ${chalk2.bold(techStack.label)} ${chalk2.gray(`(${techStack.platforms.join(", ")})`)}`);
4102
+ const brandSpinner = ora2({ text: "Reading config, brand colors, and fonts...", prefixText: " " }).start();
4103
+ const config = parseConfig(rootDir, techStack.stack);
4104
+ const branding = extractBranding(rootDir, techStack.stack);
4105
+ const detectedFonts = detectFonts(rootDir, techStack.stack);
4106
+ brandSpinner.succeed(
4107
+ `Brand: ${chalk2.gray([
4108
+ config.app_name,
4109
+ branding.primary_color && `primary ${branding.primary_color}`,
4110
+ branding.app_icon_path ? "icon found" : "no icon",
4111
+ detectedFonts.length > 0 && detectedFonts.join(", ")
4112
+ ].filter(Boolean).join(" \xB7 "))}`
4113
+ );
4114
+ const assetSpinner = ora2({ text: "Looking for app screenshots in the repo...", prefixText: " " }).start();
4115
+ const screenshots = scanAppAssets(rootDir, techStack.stack).filter((asset) => asset.asset_type === "screenshot");
4116
+ assetSpinner.succeed(
4117
+ screenshots.length > 0 ? `Screenshots: ${chalk2.bold(String(screenshots.length))} found` : `Screenshots: ${chalk2.gray("none found. You can upload them in Screenshot Studio.")}`
4118
+ );
4119
+ const sourceSpinner = ora2({ text: "Reading source code...", prefixText: " " }).start();
4120
+ const readmeContent = readReadme(rootDir);
4121
+ const sourceCode = readSourceCode(rootDir, techStack.stack);
4122
+ const projectTree = generateProjectTree(rootDir);
4123
+ sourceSpinner.succeed(`Source code: ${chalk2.bold(String(sourceCode.split("\n").length))} lines ${readmeContent ? chalk2.gray("(README found)") : ""}`);
4124
+ console.log();
4125
+ const aiSpinner = ora2({
4126
+ text: `Finding features and brand style with ${provider.name} (your source code never leaves your machine)...`,
4127
+ prefixText: " "
4128
+ }).start();
4129
+ let brief;
4130
+ try {
4131
+ brief = await generateScreenshotBrief(
4132
+ { techStack, config, branding, detectedFonts, readmeContent, sourceCode, projectTree, screenshotCount: screenshots.length },
4133
+ provider
4134
+ );
4135
+ aiSpinner.succeed(chalk2.green(`Brief ready: ${brief.features.length} features`));
4136
+ } catch (error) {
4137
+ aiSpinner.fail(chalk2.red(`AI analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`));
4138
+ process.exit(1);
4139
+ }
4140
+ for (const feature of brief.features) {
4141
+ console.log(chalk2.gray(` \u2022 ${feature.name}${feature.description ? ` \u2014 ${feature.description}` : ""}`));
4142
+ }
4143
+ console.log();
4144
+ let appAssets = [];
4145
+ if (screenshots.length > 0 || branding.app_icon_path) {
4146
+ const uploadSpinner = ora2({ text: "Uploading app icon and screenshots...", prefixText: " " }).start();
4147
+ try {
4148
+ appAssets = await uploadAssetsToStorage(client, rootDir, screenshots, branding.app_icon_path);
4149
+ uploadSpinner.succeed(`Uploaded ${appAssets.length} image${appAssets.length === 1 ? "" : "s"}`);
4150
+ } catch (error) {
4151
+ uploadSpinner.warn(chalk2.yellow(`Images were not uploaded: ${error instanceof Error ? error.message : "Unknown error"}`));
4152
+ }
4153
+ }
4154
+ const sendSpinner = ora2({ text: "Sending the brief to Screenshot Studio...", prefixText: " " }).start();
4155
+ try {
4156
+ const result = await client.submitScreenshotBrief({
4157
+ ...brief,
4158
+ app_assets: appAssets.length > 0 ? appAssets : void 0,
4159
+ cli_version: getCliVersion()
4160
+ });
4161
+ sendSpinner.succeed(chalk2.green("Brief sent! Forvibe is planning your screenshots."));
4162
+ console.log();
4163
+ console.log(chalk2.bold(" \u{1F310} Review the plan at: ") + chalk2.cyan.underline(result.web_url));
4164
+ console.log();
4165
+ } catch (error) {
4166
+ sendSpinner.fail(chalk2.red(error instanceof Error ? error.message : "Could not send the brief"));
4167
+ process.exit(1);
4168
+ }
4169
+ }
4170
+ async function screenshotsCommand(options) {
4171
+ const rootDir = options.dir || process.cwd();
4172
+ console.log();
4173
+ console.log(chalk2.bold(" Forvibe CLI") + chalk2.gray(" \u2014 Screenshot Studio"));
4174
+ console.log();
4175
+ const provider = await chooseProvider();
4176
+ const client = new ForvibeClient(options.apiUrl);
4177
+ await connectWithCode(client, "screenshots");
4178
+ await runScreenshotsFlow({ client, provider, rootDir });
4179
+ }
4180
+
4181
+ // src/commands/analyze.ts
4182
+ async function analyzeCommand(options) {
4183
+ const rootDir = options.dir || process.cwd();
4184
+ console.log();
4185
+ console.log(
4186
+ chalk3.bold(" Forvibe CLI") + chalk3.gray(" \u2014 AI-powered App Store automation")
4187
+ );
4188
+ console.log();
4189
+ const provider = await chooseProvider();
4190
+ const client = new ForvibeClient(options.apiUrl);
4191
+ await connectWithCode(client);
4192
+ if (client.flowType === "screenshots") {
4193
+ await runScreenshotsFlow({ client, provider, rootDir });
4194
+ console.log(chalk3.gray(" Tip: Screenshot Studio codes have their own command: npx @forvibe/cli screenshots\n"));
4195
+ return;
4196
+ }
4197
+ if (client.flowType === "review") {
4198
+ console.log(chalk3.yellow(" This code is for the review simulator. Run: npx @forvibe/cli review --send\n"));
4199
+ process.exit(1);
4200
+ }
4201
+ const techSpinner = ora3({
3752
4202
  text: "Detecting tech stack...",
3753
4203
  prefixText: " "
3754
4204
  }).start();
3755
4205
  const techStack = detectTechStack(rootDir);
3756
4206
  if (techStack.stack === "unknown") {
3757
4207
  techSpinner.fail(
3758
- chalk.red(
4208
+ chalk3.red(
3759
4209
  "No supported project found. Supported: Swift, Flutter, React Native, Expo, Kotlin, Capacitor, .NET MAUI"
3760
4210
  )
3761
4211
  );
3762
4212
  process.exit(1);
3763
4213
  }
3764
4214
  techSpinner.succeed(
3765
- `Tech stack: ${chalk.bold(techStack.label)} ${chalk.gray(`(${techStack.platforms.join(", ")})`)}`
4215
+ `Tech stack: ${chalk3.bold(techStack.label)} ${chalk3.gray(`(${techStack.platforms.join(", ")})`)}`
3766
4216
  );
3767
- const configSpinner = ora({
4217
+ const configSpinner = ora3({
3768
4218
  text: "Reading project configuration...",
3769
4219
  prefixText: " "
3770
4220
  }).start();
@@ -3775,17 +4225,17 @@ async function analyzeCommand(options) {
3775
4225
  config.version && `v${config.version}`
3776
4226
  ].filter(Boolean).join(" \xB7 ");
3777
4227
  configSpinner.succeed(
3778
- `Config parsed: ${chalk.gray(configDetails || "partial data")}`
4228
+ `Config parsed: ${chalk3.gray(configDetails || "partial data")}`
3779
4229
  );
3780
- const sdkSpinner = ora({
4230
+ const sdkSpinner = ora3({
3781
4231
  text: "Scanning dependencies...",
3782
4232
  prefixText: " "
3783
4233
  }).start();
3784
4234
  const sdkScan = scanSDKs(rootDir, techStack.stack);
3785
4235
  sdkSpinner.succeed(
3786
- `Dependencies: ${chalk.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
4236
+ `Dependencies: ${chalk3.bold(String(sdkScan.detected_sdks.length))} known SDKs detected`
3787
4237
  );
3788
- const brandingSpinner = ora({
4238
+ const brandingSpinner = ora3({
3789
4239
  text: "Extracting branding...",
3790
4240
  prefixText: " "
3791
4241
  }).start();
@@ -3795,17 +4245,17 @@ async function analyzeCommand(options) {
3795
4245
  branding.app_icon_path && "Icon found"
3796
4246
  ].filter(Boolean).join(" \xB7 ");
3797
4247
  brandingSpinner.succeed(
3798
- `Branding: ${chalk.gray(brandingDetails || "no colors/icon detected")}`
4248
+ `Branding: ${chalk3.gray(brandingDetails || "no colors/icon detected")}`
3799
4249
  );
3800
- const fontSpinner = ora({
4250
+ const fontSpinner = ora3({
3801
4251
  text: "Detecting fonts...",
3802
4252
  prefixText: " "
3803
4253
  }).start();
3804
4254
  const detectedFonts = detectFonts(rootDir, techStack.stack);
3805
4255
  fontSpinner.succeed(
3806
- `Fonts: ${detectedFonts.length > 0 ? chalk.white(detectedFonts.join(", ")) : chalk.gray("none detected")}`
4256
+ `Fonts: ${detectedFonts.length > 0 ? chalk3.white(detectedFonts.join(", ")) : chalk3.gray("none detected")}`
3807
4257
  );
3808
- const assetSpinner = ora({
4258
+ const assetSpinner = ora3({
3809
4259
  text: "Scanning app assets...",
3810
4260
  prefixText: " "
3811
4261
  }).start();
@@ -3817,9 +4267,9 @@ async function analyzeCommand(options) {
3817
4267
  appAssets.some((a) => a.asset_type === "promotional_image") && "promo image"
3818
4268
  ].filter(Boolean);
3819
4269
  assetSpinner.succeed(
3820
- `Assets: ${assetParts.length > 0 ? assetParts.join(", ") : "none found"} ${chalk.gray(`(${appAssets.length} total)`)}`
4270
+ `Assets: ${assetParts.length > 0 ? assetParts.join(", ") : "none found"} ${chalk3.gray(`(${appAssets.length} total)`)}`
3821
4271
  );
3822
- const sourceSpinner = ora({
4272
+ const sourceSpinner = ora3({
3823
4273
  text: "Reading source code & project structure...",
3824
4274
  prefixText: " "
3825
4275
  }).start();
@@ -3828,11 +4278,11 @@ async function analyzeCommand(options) {
3828
4278
  const projectTree = generateProjectTree(rootDir);
3829
4279
  const sourceLines = sourceCode.split("\n").length;
3830
4280
  sourceSpinner.succeed(
3831
- `Source code: ${chalk.bold(String(sourceLines))} lines analyzed ${readmeContent ? chalk.gray("(README found)") : ""}`
4281
+ `Source code: ${chalk3.bold(String(sourceLines))} lines analyzed ${readmeContent ? chalk3.gray("(README found)") : ""}`
3832
4282
  );
3833
4283
  console.log();
3834
4284
  let report;
3835
- const aiSpinner = ora({
4285
+ const aiSpinner = ora3({
3836
4286
  text: `Analyzing locally with ${provider.name} (your source code never leaves your machine)...`,
3837
4287
  prefixText: " "
3838
4288
  }).start();
@@ -3851,14 +4301,14 @@ async function analyzeCommand(options) {
3851
4301
  if (detectedFonts.length > 0) {
3852
4302
  report.detected_fonts = detectedFonts;
3853
4303
  }
3854
- aiSpinner.succeed(chalk.green("Analysis complete!"));
4304
+ aiSpinner.succeed(chalk3.green("Analysis complete!"));
3855
4305
  } catch (error) {
3856
4306
  aiSpinner.fail(
3857
- chalk.red(`AI analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`)
4307
+ chalk3.red(`AI analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`)
3858
4308
  );
3859
4309
  process.exit(1);
3860
4310
  }
3861
- const asoSpinner = ora({
4311
+ const asoSpinner = ora3({
3862
4312
  text: "Generating ASO-optimized store listing...",
3863
4313
  prefixText: " "
3864
4314
  }).start();
@@ -3867,56 +4317,56 @@ async function analyzeCommand(options) {
3867
4317
  const stores = client.storeConfig?.stores || [];
3868
4318
  const asoContent = await generateASOContent(report, provider, stores);
3869
4319
  report.aso_content = asoContent;
3870
- asoSpinner.succeed(chalk.green("Store listing content generated!"));
4320
+ asoSpinner.succeed(chalk3.green("Store listing content generated!"));
3871
4321
  } catch (error) {
3872
4322
  asoSpinner.warn(
3873
- chalk.yellow(`ASO generation skipped: ${error instanceof Error ? error.message : "Unknown error"}`)
4323
+ chalk3.yellow(`ASO generation skipped: ${error instanceof Error ? error.message : "Unknown error"}`)
3874
4324
  );
3875
4325
  }
3876
4326
  console.log();
3877
- console.log(chalk.bold(" \u{1F4CB} Report Summary"));
3878
- 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"));
3879
- console.log(` App Name: ${chalk.white(report.app_name)}`);
3880
- console.log(` Bundle ID: ${chalk.white(report.bundle_id)}`);
3881
- console.log(` Type: ${chalk.white(report.app_type)}`);
3882
- console.log(` Category: ${chalk.white(report.app_category_suggestion)}`);
4327
+ console.log(chalk3.bold(" \u{1F4CB} Report Summary"));
4328
+ console.log(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"));
4329
+ console.log(` App Name: ${chalk3.white(report.app_name)}`);
4330
+ console.log(` Bundle ID: ${chalk3.white(report.bundle_id)}`);
4331
+ console.log(` Type: ${chalk3.white(report.app_type)}`);
4332
+ console.log(` Category: ${chalk3.white(report.app_category_suggestion)}`);
3883
4333
  if (report.description) {
3884
4334
  const descPreview = report.description.length > 200 ? report.description.substring(0, 200).replace(/\n/g, " ") + "..." : report.description.replace(/\n/g, " ");
3885
- console.log(` Description: ${chalk.white(descPreview)}`);
3886
- }
3887
- console.log(` Features: ${chalk.white(report.key_features.slice(0, 3).join(", "))}${report.key_features.length > 3 ? chalk.gray(` +${report.key_features.length - 3} more`) : ""}`);
3888
- console.log(` Audience: ${chalk.white(report.target_audience.length > 100 ? report.target_audience.substring(0, 100) + "..." : report.target_audience)}`);
3889
- console.log(` SDKs: ${chalk.white(String(report.detected_sdks.length))} detected`);
3890
- console.log(` Data: ${chalk.white(report.data_collected.join(", ") || "none")}`);
3891
- 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`) : ""}`);
3892
- console.log(` Fonts: ${report.detected_fonts?.length ? chalk.white(report.detected_fonts.join(", ")) : chalk.gray("none detected")}`);
3893
- console.log(` Icon: ${report.app_icon_base64 ? chalk.green("\u2713 found") : chalk.gray("not found")}`);
3894
- console.log(` Assets: ${report.app_assets?.length ? chalk.green(`${report.app_assets.length} found`) : chalk.gray("none")}`);
3895
- 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"));
4335
+ console.log(` Description: ${chalk3.white(descPreview)}`);
4336
+ }
4337
+ console.log(` Features: ${chalk3.white(report.key_features.slice(0, 3).join(", "))}${report.key_features.length > 3 ? chalk3.gray(` +${report.key_features.length - 3} more`) : ""}`);
4338
+ console.log(` Audience: ${chalk3.white(report.target_audience.length > 100 ? report.target_audience.substring(0, 100) + "..." : report.target_audience)}`);
4339
+ console.log(` SDKs: ${chalk3.white(String(report.detected_sdks.length))} detected`);
4340
+ console.log(` Data: ${chalk3.white(report.data_collected.join(", ") || "none")}`);
4341
+ console.log(` Colors: ${chalk3.hex(report.primary_color)("\u25A0")} ${report.primary_color} ${report.secondary_color ? `${chalk3.hex(report.secondary_color)("\u25A0")} ${report.secondary_color}` : ""}${report.brand_colors?.length ? chalk3.gray(` +${report.brand_colors.length} brand colors`) : ""}`);
4342
+ console.log(` Fonts: ${report.detected_fonts?.length ? chalk3.white(report.detected_fonts.join(", ")) : chalk3.gray("none detected")}`);
4343
+ console.log(` Icon: ${report.app_icon_base64 ? chalk3.green("\u2713 found") : chalk3.gray("not found")}`);
4344
+ console.log(` Assets: ${report.app_assets?.length ? chalk3.green(`${report.app_assets.length} found`) : chalk3.gray("none")}`);
4345
+ console.log(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"));
3896
4346
  if (report.aso_content) {
3897
4347
  if (report.aso_content.appstore) {
3898
4348
  console.log();
3899
- console.log(chalk.bold(" \u{1F4F1} App Store Listing Preview"));
3900
- 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"));
4349
+ console.log(chalk3.bold(" \u{1F4F1} App Store Listing Preview"));
4350
+ console.log(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"));
3901
4351
  const aso = report.aso_content.appstore;
3902
- console.log(` Title: ${chalk.white(aso.app_name)} ${chalk.gray(`(${aso.app_name.length}/30)`)}`);
3903
- console.log(` Subtitle: ${chalk.white(aso.subtitle)} ${chalk.gray(`(${aso.subtitle.length}/30)`)}`);
3904
- console.log(` Keywords: ${chalk.white(aso.keywords)} ${chalk.gray(`(${aso.keywords.length}/100)`)}`);
3905
- console.log(` Promo: ${chalk.white(aso.promotional_text)} ${chalk.gray(`(${aso.promotional_text.length}/170)`)}`);
4352
+ console.log(` Title: ${chalk3.white(aso.app_name)} ${chalk3.gray(`(${aso.app_name.length}/30)`)}`);
4353
+ console.log(` Subtitle: ${chalk3.white(aso.subtitle)} ${chalk3.gray(`(${aso.subtitle.length}/30)`)}`);
4354
+ console.log(` Keywords: ${chalk3.white(aso.keywords)} ${chalk3.gray(`(${aso.keywords.length}/100)`)}`);
4355
+ console.log(` Promo: ${chalk3.white(aso.promotional_text)} ${chalk3.gray(`(${aso.promotional_text.length}/170)`)}`);
3906
4356
  const descPreview = aso.description.substring(0, 150).replace(/\n/g, " ");
3907
- console.log(` Description: ${chalk.white(descPreview)}${aso.description.length > 150 ? chalk.gray("...") : ""} ${chalk.gray(`(${aso.description.length}/4000)`)}`);
3908
- 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"));
4357
+ console.log(` Description: ${chalk3.white(descPreview)}${aso.description.length > 150 ? chalk3.gray("...") : ""} ${chalk3.gray(`(${aso.description.length}/4000)`)}`);
4358
+ console.log(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"));
3909
4359
  }
3910
4360
  if (report.aso_content.playstore) {
3911
4361
  console.log();
3912
- console.log(chalk.bold(" \u{1F916} Play Store Listing Preview"));
3913
- 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"));
4362
+ console.log(chalk3.bold(" \u{1F916} Play Store Listing Preview"));
4363
+ console.log(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"));
3914
4364
  const ps = report.aso_content.playstore;
3915
- console.log(` Title: ${chalk.white(ps.title)} ${chalk.gray(`(${ps.title.length}/30)`)}`);
3916
- console.log(` Short Desc: ${chalk.white(ps.short_description)} ${chalk.gray(`(${ps.short_description.length}/80)`)}`);
4365
+ console.log(` Title: ${chalk3.white(ps.title)} ${chalk3.gray(`(${ps.title.length}/30)`)}`);
4366
+ console.log(` Short Desc: ${chalk3.white(ps.short_description)} ${chalk3.gray(`(${ps.short_description.length}/80)`)}`);
3917
4367
  const psDescPreview = ps.description.substring(0, 150).replace(/\n/g, " ");
3918
- console.log(` Description: ${chalk.white(psDescPreview)}${ps.description.length > 150 ? chalk.gray("...") : ""} ${chalk.gray(`(${ps.description.length}/4000)`)}`);
3919
- 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"));
4368
+ console.log(` Description: ${chalk3.white(psDescPreview)}${ps.description.length > 150 ? chalk3.gray("...") : ""} ${chalk3.gray(`(${ps.description.length}/4000)`)}`);
4369
+ console.log(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"));
3920
4370
  }
3921
4371
  }
3922
4372
  const selectedStores = client.storeConfig?.stores || [];
@@ -3924,40 +4374,40 @@ async function analyzeCommand(options) {
3924
4374
  const hasPS = selectedStores.includes("playstore") || selectedStores.length === 0;
3925
4375
  if (hasASC) {
3926
4376
  console.log();
3927
- console.log(chalk.bold(" \u{1F4DD} App Review Information"));
3928
- 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"));
3929
- console.log(chalk.gray(" Apple's review team may need to sign in to your app."));
3930
- console.log(chalk.gray(" You can also provide this later in the Forvibe dashboard."));
4377
+ console.log(chalk3.bold(" \u{1F4DD} App Review Information"));
4378
+ console.log(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"));
4379
+ console.log(chalk3.gray(" Apple's review team may need to sign in to your app."));
4380
+ console.log(chalk3.gray(" You can also provide this later in the Forvibe dashboard."));
3931
4381
  console.log();
3932
4382
  const hasAuthSDKs = (sdkScan.third_party_services.auth?.length ?? 0) > 0;
3933
4383
  if (hasAuthSDKs) {
3934
4384
  const signInAnswer = await askQuestion(
3935
- chalk.cyan(" Does your app require sign-in to use? (y/n): ")
4385
+ chalk3.cyan(" Does your app require sign-in to use? (y/n): ")
3936
4386
  );
3937
4387
  const signInRequired = signInAnswer.toLowerCase().startsWith("y");
3938
4388
  report.sign_in_required = signInRequired;
3939
4389
  if (signInRequired) {
3940
4390
  console.log();
3941
- console.log(chalk.yellow(" \u26A0 Please create a test account in your app for Apple's review team."));
3942
- console.log(chalk.gray(" This account should have full access to your app's features."));
4391
+ console.log(chalk3.yellow(" \u26A0 Please create a test account in your app for Apple's review team."));
4392
+ console.log(chalk3.gray(" This account should have full access to your app's features."));
3943
4393
  console.log();
3944
- const testEmail = await askQuestion(chalk.cyan(" Test account email: "));
3945
- const testPassword = await askQuestion(chalk.cyan(" Test account password: "));
4394
+ const testEmail = await askQuestion(chalk3.cyan(" Test account email: "));
4395
+ const testPassword = await askQuestion(chalk3.cyan(" Test account password: "));
3946
4396
  if (testEmail && testPassword) {
3947
4397
  report.test_account = { email: testEmail, password: testPassword };
3948
- console.log(chalk.green(" \u2713 Test account saved"));
4398
+ console.log(chalk3.green(" \u2713 Test account saved"));
3949
4399
  }
3950
4400
  }
3951
4401
  } else {
3952
4402
  report.sign_in_required = false;
3953
4403
  }
3954
4404
  console.log();
3955
- console.log(chalk.gray(" Contact information for Apple's review team (press Enter to skip):"));
4405
+ console.log(chalk3.gray(" Contact information for Apple's review team (press Enter to skip):"));
3956
4406
  console.log();
3957
- const firstName = await askQuestion(chalk.cyan(" First name: "));
3958
- const lastName = await askQuestion(chalk.cyan(" Last name: "));
3959
- const contactEmail = await askQuestion(chalk.cyan(" Email: "));
3960
- const contactPhone = await askQuestion(chalk.cyan(" Phone (e.g. +1 555 123 4567): "));
4407
+ const firstName = await askQuestion(chalk3.cyan(" First name: "));
4408
+ const lastName = await askQuestion(chalk3.cyan(" Last name: "));
4409
+ const contactEmail = await askQuestion(chalk3.cyan(" Email: "));
4410
+ const contactPhone = await askQuestion(chalk3.cyan(" Phone (e.g. +1 555 123 4567): "));
3961
4411
  if (firstName && lastName && contactEmail) {
3962
4412
  report.review_contact = {
3963
4413
  first_name: firstName,
@@ -3965,18 +4415,18 @@ async function analyzeCommand(options) {
3965
4415
  email: contactEmail,
3966
4416
  phone: contactPhone || ""
3967
4417
  };
3968
- console.log(chalk.green(" \u2713 Contact info saved"));
4418
+ console.log(chalk3.green(" \u2713 Contact info saved"));
3969
4419
  } else {
3970
- console.log(chalk.gray(" \u2139 Skipped \u2014 you can add this later in the dashboard."));
4420
+ console.log(chalk3.gray(" \u2139 Skipped \u2014 you can add this later in the dashboard."));
3971
4421
  }
3972
- 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"));
4422
+ console.log(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"));
3973
4423
  }
3974
4424
  if (hasPS) {
3975
4425
  console.log();
3976
- console.log(chalk.bold(" \u{1F916} Play Store Declarations"));
3977
- 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"));
3978
- console.log(chalk.gray(" Google Play requires content declarations before publishing."));
3979
- console.log(chalk.gray(" We'll auto-detect what we can and ask you to confirm."));
4426
+ console.log(chalk3.bold(" \u{1F916} Play Store Declarations"));
4427
+ console.log(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"));
4428
+ console.log(chalk3.gray(" Google Play requires content declarations before publishing."));
4429
+ console.log(chalk3.gray(" We'll auto-detect what we can and ask you to confirm."));
3980
4430
  console.log();
3981
4431
  const hasAdSDKs = (sdkScan.third_party_services.ads?.length ?? 0) > 0;
3982
4432
  const hasPaymentSDKs = (sdkScan.third_party_services.payment?.length ?? 0) > 0;
@@ -3995,48 +4445,48 @@ async function analyzeCommand(options) {
3995
4445
  if (report.test_account) {
3996
4446
  appAccessInstructions = `Test account: ${report.test_account.email} / ${report.test_account.password}`;
3997
4447
  }
3998
- console.log(chalk.white(" App Access: ") + chalk.yellow("Restricted (login required)"));
4448
+ console.log(chalk3.white(" App Access: ") + chalk3.yellow("Restricted (login required)"));
3999
4449
  } else {
4000
4450
  const accessAnswer = await askQuestion(
4001
- chalk.cyan(" Does your app have restricted access (login, special permissions, etc.)? (y/n): ")
4451
+ chalk3.cyan(" Does your app have restricted access (login, special permissions, etc.)? (y/n): ")
4002
4452
  );
4003
4453
  if (accessAnswer.toLowerCase().startsWith("y")) {
4004
4454
  appAccess = "restricted_login";
4005
4455
  const instrAnswer = await askQuestion(
4006
- chalk.cyan(" How can a reviewer access all features? (describe or Enter to skip): ")
4456
+ chalk3.cyan(" How can a reviewer access all features? (describe or Enter to skip): ")
4007
4457
  );
4008
4458
  if (instrAnswer) appAccessInstructions = instrAnswer;
4009
4459
  } else {
4010
- console.log(chalk.green(" \u2713 App is freely accessible"));
4460
+ console.log(chalk3.green(" \u2713 App is freely accessible"));
4011
4461
  }
4012
4462
  }
4013
4463
  let containsAds = hasAdSDKs || report.advertising_type !== "none";
4014
4464
  if (containsAds) {
4015
- console.log(chalk.white(" Ads: ") + chalk.yellow(`Detected (${sdkScan.third_party_services.ads?.join(", ") || report.advertising_type})`));
4465
+ console.log(chalk3.white(" Ads: ") + chalk3.yellow(`Detected (${sdkScan.third_party_services.ads?.join(", ") || report.advertising_type})`));
4016
4466
  const adsConfirm = await askQuestion(
4017
- chalk.cyan(" Does your app contain ads? (Y/n): ")
4467
+ chalk3.cyan(" Does your app contain ads? (Y/n): ")
4018
4468
  );
4019
4469
  if (adsConfirm.toLowerCase() === "n") containsAds = false;
4020
4470
  } else {
4021
4471
  const adsAnswer = await askQuestion(
4022
- chalk.cyan(" Does your app contain ads? (y/N): ")
4472
+ chalk3.cyan(" Does your app contain ads? (y/N): ")
4023
4473
  );
4024
4474
  containsAds = adsAnswer.toLowerCase().startsWith("y");
4025
4475
  }
4026
- console.log(containsAds ? chalk.yellow(" \u2192 Contains ads") : chalk.green(" \u2713 No ads"));
4476
+ console.log(containsAds ? chalk3.yellow(" \u2192 Contains ads") : chalk3.green(" \u2713 No ads"));
4027
4477
  let targetAgeGroup = "all_ages";
4028
4478
  let isAppealToChildren = false;
4029
4479
  if (report.is_for_children) {
4030
- console.log(chalk.yellow(" \u26A0 App appears to target children"));
4480
+ console.log(chalk3.yellow(" \u26A0 App appears to target children"));
4031
4481
  targetAgeGroup = "all_ages";
4032
4482
  isAppealToChildren = true;
4033
4483
  } else {
4034
4484
  console.log();
4035
- console.log(chalk.gray(" Target age groups:"));
4036
- console.log(chalk.gray(" 1. All ages (including children under 13)"));
4037
- console.log(chalk.gray(" 2. Older users only (13+ or 18+)"));
4038
- console.log(chalk.gray(" 3. Mixed (targets both children and older users)"));
4039
- const ageAnswer = await askQuestion(chalk.cyan(" Choose target audience (1/2/3): "));
4485
+ console.log(chalk3.gray(" Target age groups:"));
4486
+ console.log(chalk3.gray(" 1. All ages (including children under 13)"));
4487
+ console.log(chalk3.gray(" 2. Older users only (13+ or 18+)"));
4488
+ console.log(chalk3.gray(" 3. Mixed (targets both children and older users)"));
4489
+ const ageAnswer = await askQuestion(chalk3.cyan(" Choose target audience (1/2/3): "));
4040
4490
  if (ageAnswer === "2") {
4041
4491
  targetAgeGroup = "older_users_only";
4042
4492
  } else if (ageAnswer === "3") {
@@ -4045,52 +4495,52 @@ async function analyzeCommand(options) {
4045
4495
  } else {
4046
4496
  targetAgeGroup = "all_ages";
4047
4497
  const appealAnswer = await askQuestion(
4048
- chalk.cyan(" Could the app appeal to children even if not targeted at them? (y/N): ")
4498
+ chalk3.cyan(" Could the app appeal to children even if not targeted at them? (y/N): ")
4049
4499
  );
4050
4500
  isAppealToChildren = appealAnswer.toLowerCase().startsWith("y");
4051
4501
  }
4052
4502
  }
4053
4503
  const govAnswer = await askQuestion(
4054
- chalk.cyan(" Is this a government app? (y/N): ")
4504
+ chalk3.cyan(" Is this a government app? (y/N): ")
4055
4505
  );
4056
4506
  const isGovernmentApp = govAnswer.toLowerCase().startsWith("y");
4057
4507
  let hasFinancialFeatures = isFinanceApp || hasFinanceSDKs;
4058
4508
  if (hasFinancialFeatures) {
4059
- console.log(chalk.white(" Financial features: ") + chalk.yellow("Detected"));
4509
+ console.log(chalk3.white(" Financial features: ") + chalk3.yellow("Detected"));
4060
4510
  const finConfirm = await askQuestion(
4061
- chalk.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (Y/n): ")
4511
+ chalk3.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (Y/n): ")
4062
4512
  );
4063
4513
  if (finConfirm.toLowerCase() === "n") hasFinancialFeatures = false;
4064
4514
  } else {
4065
4515
  const finAnswer = await askQuestion(
4066
- chalk.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (y/N): ")
4516
+ chalk3.cyan(" Does your app offer financial features (payments, digital wallet, crypto, etc.)? (y/N): ")
4067
4517
  );
4068
4518
  hasFinancialFeatures = finAnswer.toLowerCase().startsWith("y");
4069
4519
  }
4070
4520
  let financialDetails;
4071
4521
  if (hasFinancialFeatures) {
4072
4522
  const finDetailsAnswer = await askQuestion(
4073
- chalk.cyan(" Describe financial features briefly (or Enter to skip): ")
4523
+ chalk3.cyan(" Describe financial features briefly (or Enter to skip): ")
4074
4524
  );
4075
4525
  if (finDetailsAnswer) financialDetails = finDetailsAnswer;
4076
4526
  }
4077
4527
  let isHealthAppDecl = isHealthApp || hasHealthSDKs;
4078
4528
  if (isHealthAppDecl) {
4079
- console.log(chalk.white(" Health app: ") + chalk.yellow("Detected"));
4529
+ console.log(chalk3.white(" Health app: ") + chalk3.yellow("Detected"));
4080
4530
  const healthConfirm = await askQuestion(
4081
- chalk.cyan(" Is this a health or fitness app? (Y/n): ")
4531
+ chalk3.cyan(" Is this a health or fitness app? (Y/n): ")
4082
4532
  );
4083
4533
  if (healthConfirm.toLowerCase() === "n") isHealthAppDecl = false;
4084
4534
  } else {
4085
4535
  const healthAnswer = await askQuestion(
4086
- chalk.cyan(" Is this a health or fitness app? (y/N): ")
4536
+ chalk3.cyan(" Is this a health or fitness app? (y/N): ")
4087
4537
  );
4088
4538
  isHealthAppDecl = healthAnswer.toLowerCase().startsWith("y");
4089
4539
  }
4090
4540
  let healthDetails;
4091
4541
  if (isHealthAppDecl) {
4092
4542
  const healthDetailsAnswer = await askQuestion(
4093
- chalk.cyan(" Describe health features briefly (or Enter to skip): ")
4543
+ chalk3.cyan(" Describe health features briefly (or Enter to skip): ")
4094
4544
  );
4095
4545
  if (healthDetailsAnswer) healthDetails = healthDetailsAnswer;
4096
4546
  }
@@ -4107,14 +4557,14 @@ async function analyzeCommand(options) {
4107
4557
  health_app_details: healthDetails
4108
4558
  };
4109
4559
  console.log();
4110
- console.log(chalk.green(" \u2713 Play Store declarations collected"));
4111
- console.log(chalk.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
4560
+ console.log(chalk3.green(" \u2713 Play Store declarations collected"));
4561
+ console.log(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"));
4112
4562
  }
4113
4563
  console.log();
4114
4564
  try {
4115
4565
  const uploadCount = (report.app_assets?.length ?? 0) + (branding.app_icon_path ? 1 : 0);
4116
4566
  if (uploadCount > 0) {
4117
- const uploadSpinner = ora({
4567
+ const uploadSpinner = ora3({
4118
4568
  text: `Uploading ${uploadCount} asset${uploadCount === 1 ? "" : "s"}...`,
4119
4569
  prefixText: " "
4120
4570
  }).start();
@@ -4127,10 +4577,10 @@ async function analyzeCommand(options) {
4127
4577
  );
4128
4578
  report.app_assets = uploaded;
4129
4579
  report.app_icon_base64 = null;
4130
- uploadSpinner.succeed(chalk.green(`Uploaded ${uploaded.length} asset${uploaded.length === 1 ? "" : "s"}`));
4580
+ uploadSpinner.succeed(chalk3.green(`Uploaded ${uploaded.length} asset${uploaded.length === 1 ? "" : "s"}`));
4131
4581
  } catch (uploadError) {
4132
4582
  uploadSpinner.fail(
4133
- chalk.red(
4583
+ chalk3.red(
4134
4584
  `Asset upload failed: ${uploadError instanceof Error ? uploadError.message : "Unknown error"}`
4135
4585
  )
4136
4586
  );
@@ -4138,24 +4588,24 @@ async function analyzeCommand(options) {
4138
4588
  }
4139
4589
  }
4140
4590
  } catch (e) {
4141
- console.error(chalk.red(`Asset upload error: ${e instanceof Error ? e.message : String(e)}`));
4591
+ console.error(chalk3.red(`Asset upload error: ${e instanceof Error ? e.message : String(e)}`));
4142
4592
  process.exit(1);
4143
4593
  }
4144
- const sendSpinner = ora({
4594
+ const sendSpinner = ora3({
4145
4595
  text: "Sending report to Forvibe...",
4146
4596
  prefixText: " "
4147
4597
  }).start();
4148
4598
  try {
4149
4599
  const result = await client.submitReport(report);
4150
- sendSpinner.succeed(chalk.green("Report sent successfully!"));
4600
+ sendSpinner.succeed(chalk3.green("Report sent successfully!"));
4151
4601
  console.log();
4152
4602
  console.log(
4153
- chalk.bold(" \u{1F310} Continue setup at: ") + chalk.cyan.underline(result.web_url)
4603
+ chalk3.bold(" \u{1F310} Continue setup at: ") + chalk3.cyan.underline(result.web_url)
4154
4604
  );
4155
4605
  console.log();
4156
4606
  } catch (error) {
4157
4607
  sendSpinner.fail(
4158
- chalk.red(
4608
+ chalk3.red(
4159
4609
  `Failed to send report: ${error instanceof Error ? error.message : "Unknown error"}`
4160
4610
  )
4161
4611
  );
@@ -4165,11 +4615,188 @@ async function analyzeCommand(options) {
4165
4615
 
4166
4616
  // src/commands/review.ts
4167
4617
  import { createInterface as createInterface2 } from "readline";
4168
- import chalk3 from "chalk";
4169
- import ora2 from "ora";
4618
+ import chalk5 from "chalk";
4619
+ import ora4 from "ora";
4170
4620
 
4171
- // src/engine/index.ts
4172
- import { join as join14 } from "path";
4621
+ // src/engine/source-corpus.ts
4622
+ import { statSync as statSync7 } from "fs";
4623
+ import { join as join9 } from "path";
4624
+ var CORPUS_MAX_FILES = 4e3;
4625
+ var CORPUS_MAX_BYTES = 24 * 1024 * 1024;
4626
+ var CORPUS_MAX_FILE_BYTES = 4 * 1024 * 1024;
4627
+ function extensionsForStack(stack) {
4628
+ switch (stack) {
4629
+ case "flutter":
4630
+ return [".dart"];
4631
+ case "swift":
4632
+ return [".swift", ".m", ".mm", ".h"];
4633
+ case "kotlin":
4634
+ return [".kt", ".kts"];
4635
+ case "expo":
4636
+ case "react-native":
4637
+ case "capacitor":
4638
+ return [".ts", ".tsx", ".js", ".jsx"];
4639
+ case "dotnet-maui":
4640
+ return [".cs", ".xaml"];
4641
+ case "unity":
4642
+ return [".cs"];
4643
+ case "kmp":
4644
+ return [".kt", ".kts", ".swift"];
4645
+ default:
4646
+ return [".ts", ".js", ".swift", ".dart", ".kt"];
4647
+ }
4648
+ }
4649
+ function readSourceCorpus(rootDir, stack, opts = {}) {
4650
+ const maxFiles = opts.maxFiles ?? CORPUS_MAX_FILES;
4651
+ const maxBytes = opts.maxBytes ?? CORPUS_MAX_BYTES;
4652
+ const limits = /* @__PURE__ */ new Set();
4653
+ const relPaths = scanDirectory(rootDir, {
4654
+ extensions: extensionsForStack(stack),
4655
+ maxDepth: 8,
4656
+ maxFiles: maxFiles + 1
4657
+ }).sort();
4658
+ let discovered = relPaths.length;
4659
+ let paths = relPaths;
4660
+ if (discovered > maxFiles) {
4661
+ limits.add("walker_saturated");
4662
+ limits.add("max_files");
4663
+ paths = relPaths.slice(0, maxFiles);
4664
+ discovered = maxFiles;
4665
+ }
4666
+ const files = [];
4667
+ let filtered = 0;
4668
+ let totalBytes = 0;
4669
+ for (const rel of paths) {
4670
+ if (totalBytes >= maxBytes) {
4671
+ limits.add("max_bytes");
4672
+ break;
4673
+ }
4674
+ const abs = join9(rootDir, rel);
4675
+ const content = readFileSafe(abs, CORPUS_MAX_FILE_BYTES);
4676
+ if (content === null) {
4677
+ let oversize = false;
4678
+ try {
4679
+ oversize = statSync7(abs).size > CORPUS_MAX_FILE_BYTES;
4680
+ } catch {
4681
+ oversize = false;
4682
+ }
4683
+ limits.add(oversize ? "oversize_file" : "read_error");
4684
+ continue;
4685
+ }
4686
+ if (content.trim().length < 20 || isTestFile(rel) || isGeneratedFile(rel, content)) {
4687
+ filtered += 1;
4688
+ continue;
4689
+ }
4690
+ files.push({ path: rel, content });
4691
+ totalBytes += content.length;
4692
+ }
4693
+ const attempted = files.length + filtered;
4694
+ const complete = limits.size === 0 && attempted === discovered;
4695
+ return {
4696
+ files,
4697
+ coverage: {
4698
+ discovered,
4699
+ read: files.length,
4700
+ filtered,
4701
+ total_bytes: totalBytes,
4702
+ complete,
4703
+ limits_hit: Array.from(limits).sort()
4704
+ }
4705
+ };
4706
+ }
4707
+
4708
+ // src/review/ai-corpus.ts
4709
+ var EXCERPT_RADIUS = 3e3;
4710
+ var EVIDENCE_BUDGET_SHARE = 0.4;
4711
+ function excerptAround(content, offsets, maxChars, radius = EXCERPT_RADIUS) {
4712
+ if (content.length <= maxChars) return content;
4713
+ const windows = offsets.map((o) => ({
4714
+ start: Math.max(0, o - radius),
4715
+ end: Math.min(content.length, o + radius)
4716
+ })).sort((a, b) => a.start - b.start);
4717
+ const merged = [];
4718
+ for (const w of windows) {
4719
+ const last = merged[merged.length - 1];
4720
+ if (last && w.start <= last.end) {
4721
+ last.end = Math.max(last.end, w.end);
4722
+ } else {
4723
+ merged.push({ ...w });
4724
+ }
4725
+ }
4726
+ const parts = [];
4727
+ let used = 0;
4728
+ let prevEnd = 0;
4729
+ for (const w of merged) {
4730
+ if (used >= maxChars) break;
4731
+ const slice = content.slice(w.start, Math.min(w.end, w.start + (maxChars - used)));
4732
+ if (w.start > prevEnd) {
4733
+ parts.push(`
4734
+ // ... [${w.start - prevEnd} chars omitted] ...
4735
+ `);
4736
+ }
4737
+ parts.push(slice);
4738
+ used += slice.length;
4739
+ prevEnd = w.start + slice.length;
4740
+ }
4741
+ if (prevEnd < content.length) {
4742
+ parts.push(`
4743
+ // ... [${content.length - prevEnd} chars omitted] ...
4744
+ `);
4745
+ }
4746
+ return parts.join("");
4747
+ }
4748
+ function selectAiCorpus(corpus, hits, stack, maxTotalChars, perFileChars = 15e3) {
4749
+ const byPath = new Map(corpus.files.map((f) => [f.path, f]));
4750
+ const inventory = corpus.files.map((f) => f.path);
4751
+ const hitsByPath = /* @__PURE__ */ new Map();
4752
+ for (const hit of hits) {
4753
+ if (!byPath.has(hit.path)) continue;
4754
+ let entry = hitsByPath.get(hit.path);
4755
+ if (!entry) {
4756
+ entry = { signals: /* @__PURE__ */ new Set(), offsets: [] };
4757
+ hitsByPath.set(hit.path, entry);
4758
+ }
4759
+ entry.signals.add(hit.signal);
4760
+ entry.offsets.push(hit.index);
4761
+ }
4762
+ const evidencePaths = Array.from(hitsByPath.entries()).sort((a, b) => {
4763
+ const d = b[1].signals.size - a[1].signals.size;
4764
+ return d !== 0 ? d : a[0].localeCompare(b[0]);
4765
+ }).map(([path2]) => path2);
4766
+ const files = [];
4767
+ const taken = /* @__PURE__ */ new Set();
4768
+ let totalChars = 0;
4769
+ const evidenceBudget = Math.floor(maxTotalChars * EVIDENCE_BUDGET_SHARE);
4770
+ for (const path2 of evidencePaths) {
4771
+ if (totalChars >= evidenceBudget) break;
4772
+ const file = byPath.get(path2);
4773
+ if (!file) continue;
4774
+ const entry = hitsByPath.get(path2);
4775
+ const content = excerptAround(file.content, entry?.offsets ?? [0], perFileChars);
4776
+ files.push({ path: path2, content });
4777
+ taken.add(path2);
4778
+ totalChars += content.length;
4779
+ }
4780
+ const evidenceFileCount = files.length;
4781
+ const remaining = corpus.files.filter((f) => !taken.has(f.path)).map((f) => f.path);
4782
+ const sorted = sortByPriority(remaining, getPriorityPatternsForStack(stack));
4783
+ for (const path2 of sorted) {
4784
+ if (totalChars >= maxTotalChars) break;
4785
+ const file = byPath.get(path2);
4786
+ if (!file) continue;
4787
+ const content = file.content.substring(0, perFileChars);
4788
+ files.push({ path: path2, content });
4789
+ taken.add(path2);
4790
+ totalChars += content.length;
4791
+ }
4792
+ return {
4793
+ files,
4794
+ inventory,
4795
+ totalChars,
4796
+ evidenceFileCount,
4797
+ selectedFrom: corpus.files.length
4798
+ };
4799
+ }
4173
4800
 
4174
4801
  // src/engine/registry.ts
4175
4802
  var ECOSYSTEM_KEYS = [
@@ -4252,7 +4879,7 @@ function matchSDKs(deps, registry) {
4252
4879
  }
4253
4880
 
4254
4881
  // src/engine/extractors/ios-plist.ts
4255
- import { join as join9, relative as relative6 } from "path";
4882
+ import { join as join10, relative as relative6 } from "path";
4256
4883
  import plistLib from "plist";
4257
4884
 
4258
4885
  // src/engine/extractors/shared.ts
@@ -4447,7 +5074,7 @@ function extractIosPlist(rootDir) {
4447
5074
  }
4448
5075
  }
4449
5076
  let expoData = null;
4450
- const appJsonContent = readFileSafe(join9(rootDir, "app.json"));
5077
+ const appJsonContent = readFileSafe(join10(rootDir, "app.json"));
4451
5078
  if (appJsonContent) {
4452
5079
  try {
4453
5080
  const appJson = JSON.parse(appJsonContent);
@@ -4465,7 +5092,7 @@ function extractIosPlist(rootDir) {
4465
5092
  }
4466
5093
  if (!expoData) {
4467
5094
  for (const name of ["app.config.ts", "app.config.js"]) {
4468
- const content = readFileSafe(join9(rootDir, name));
5095
+ const content = readFileSafe(join10(rootDir, name));
4469
5096
  if (!content) continue;
4470
5097
  const found = readExpoConfigFallback(content);
4471
5098
  if (Object.keys(found).length > 0) {
@@ -4645,16 +5272,16 @@ function extractIosPrivacyManifest(rootDir) {
4645
5272
  }
4646
5273
 
4647
5274
  // src/engine/extractors/android-manifest.ts
4648
- import { join as join10, relative as relative9 } from "path";
5275
+ import { join as join11, relative as relative9 } from "path";
4649
5276
  var EXCLUDED_SEGMENTS = /* @__PURE__ */ new Set(["build", "test", "androidTest", "debug"]);
4650
5277
  function isExcludedManifestPath(relPath) {
4651
5278
  return relPath.split(/[\\/]/).some((segment) => EXCLUDED_SEGMENTS.has(segment));
4652
5279
  }
4653
5280
  function findMainManifest(rootDir) {
4654
5281
  const knownCandidates = [
4655
- join10(rootDir, "app/src/main/AndroidManifest.xml"),
4656
- join10(rootDir, "android/app/src/main/AndroidManifest.xml"),
4657
- join10(rootDir, "src/main/AndroidManifest.xml")
5282
+ join11(rootDir, "app/src/main/AndroidManifest.xml"),
5283
+ join11(rootDir, "android/app/src/main/AndroidManifest.xml"),
5284
+ join11(rootDir, "src/main/AndroidManifest.xml")
4658
5285
  ];
4659
5286
  for (const candidate of knownCandidates) {
4660
5287
  if (fileExists(candidate)) return candidate;
@@ -4719,16 +5346,16 @@ function extractAndroidManifest(rootDir) {
4719
5346
  }
4720
5347
 
4721
5348
  // src/engine/extractors/gradle-targets.ts
4722
- import { join as join11, relative as relative10 } from "path";
5349
+ import { join as join12, relative as relative10 } from "path";
4723
5350
  function candidateGradleFiles(rootDir) {
4724
5351
  const candidates = [];
4725
5352
  const add = (p) => {
4726
5353
  if (p && !candidates.includes(p)) candidates.push(p);
4727
5354
  };
4728
- add(findFile(join11(rootDir, "app"), "build.gradle", 2));
4729
- add(findFile(join11(rootDir, "app"), "build.gradle.kts", 2));
4730
- add(findFile(join11(rootDir, "android/app"), "build.gradle", 2));
4731
- add(findFile(join11(rootDir, "android/app"), "build.gradle.kts", 2));
5355
+ add(findFile(join12(rootDir, "app"), "build.gradle", 2));
5356
+ add(findFile(join12(rootDir, "app"), "build.gradle.kts", 2));
5357
+ add(findFile(join12(rootDir, "android/app"), "build.gradle", 2));
5358
+ add(findFile(join12(rootDir, "android/app"), "build.gradle.kts", 2));
4732
5359
  add(findFile(rootDir, "build.gradle", 1));
4733
5360
  add(findFile(rootDir, "build.gradle.kts", 1));
4734
5361
  for (const f of findFiles(rootDir, ["build.gradle", "build.gradle.kts"], 4)) {
@@ -4772,9 +5399,9 @@ function extractGradleTargets(rootDir) {
4772
5399
  }
4773
5400
 
4774
5401
  // src/engine/extractors/unity.ts
4775
- import { join as join12 } from "path";
5402
+ import { join as join13 } from "path";
4776
5403
  import YAML3 from "yaml";
4777
- var PROJECT_SETTINGS_REL = join12("ProjectSettings", "ProjectSettings.asset");
5404
+ var PROJECT_SETTINGS_REL = join13("ProjectSettings", "ProjectSettings.asset");
4778
5405
  function toPlainYaml(raw) {
4779
5406
  return raw.split("\n").filter((line) => !line.startsWith("%")).map((line) => /^---\s*!u!/.test(line) ? "---" : line).join("\n");
4780
5407
  }
@@ -4792,7 +5419,7 @@ function regexFallbackAppIds(raw) {
4792
5419
  };
4793
5420
  }
4794
5421
  function extractUnityProjectSettings(rootDir) {
4795
- const filePath = join12(rootDir, "ProjectSettings", "ProjectSettings.asset");
5422
+ const filePath = join13(rootDir, "ProjectSettings", "ProjectSettings.asset");
4796
5423
  const raw = readFileSafe(filePath);
4797
5424
  if (raw === null) return null;
4798
5425
  const evidence = {
@@ -4833,8 +5460,8 @@ function extractUnityProjectSettings(rootDir) {
4833
5460
  }
4834
5461
 
4835
5462
  // src/engine/extractors/kmp.ts
4836
- import { readdirSync as readdirSync9, statSync as statSync7, existsSync as existsSync6 } from "fs";
4837
- import { join as join13 } from "path";
5463
+ import { readdirSync as readdirSync9, statSync as statSync8, existsSync as existsSync6 } from "fs";
5464
+ import { join as join14 } from "path";
4838
5465
  function containsXcodeproj(dir, maxDepth) {
4839
5466
  function walk(current, depth) {
4840
5467
  let entries;
@@ -4844,10 +5471,10 @@ function containsXcodeproj(dir, maxDepth) {
4844
5471
  return false;
4845
5472
  }
4846
5473
  for (const entry of entries) {
4847
- const full = join13(current, entry);
5474
+ const full = join14(current, entry);
4848
5475
  let isDirectory = false;
4849
5476
  try {
4850
- isDirectory = statSync7(full).isDirectory();
5477
+ isDirectory = statSync8(full).isDirectory();
4851
5478
  } catch {
4852
5479
  continue;
4853
5480
  }
@@ -4862,11 +5489,11 @@ function containsXcodeproj(dir, maxDepth) {
4862
5489
  var ANDROID_APPLICATION_MODULE_RE = /applicationId\b|com\.android\.application/;
4863
5490
  var KMP_PLUGIN_MARKER_RE2 = /kotlin\(\s*["']multiplatform["']\s*\)|id\(\s*["']org\.jetbrains\.kotlin\.multiplatform["']\s*\)|kotlinMultiplatform/;
4864
5491
  function readModuleGradle(moduleDir) {
4865
- return readFileSafe(join13(moduleDir, "build.gradle.kts")) ?? readFileSafe(join13(moduleDir, "build.gradle"));
5492
+ return readFileSafe(join14(moduleDir, "build.gradle.kts")) ?? readFileSafe(join14(moduleDir, "build.gradle"));
4866
5493
  }
4867
5494
  function findKmpLayout(rootDir) {
4868
5495
  let iosAppDir = null;
4869
- const iosAppCandidate = join13(rootDir, "iosApp");
5496
+ const iosAppCandidate = join14(rootDir, "iosApp");
4870
5497
  if (existsSync6(iosAppCandidate)) {
4871
5498
  const hasXcodeproj = containsXcodeproj(iosAppCandidate, 3);
4872
5499
  const hasInfoPlist = findFile(iosAppCandidate, "Info.plist", 3) !== null;
@@ -4874,7 +5501,7 @@ function findKmpLayout(rootDir) {
4874
5501
  }
4875
5502
  let androidModuleDir = null;
4876
5503
  for (const name of ["androidApp", "composeApp", "app"]) {
4877
- const candidate = join13(rootDir, name);
5504
+ const candidate = join14(rootDir, name);
4878
5505
  const content = readModuleGradle(candidate);
4879
5506
  if (content && ANDROID_APPLICATION_MODULE_RE.test(content)) {
4880
5507
  androidModuleDir = candidate;
@@ -4885,10 +5512,10 @@ function findKmpLayout(rootDir) {
4885
5512
  try {
4886
5513
  const entries = [...readdirSync9(rootDir)].sort();
4887
5514
  for (const entry of entries) {
4888
- const candidate = join13(rootDir, entry);
5515
+ const candidate = join14(rootDir, entry);
4889
5516
  let isDirectory = false;
4890
5517
  try {
4891
- isDirectory = statSync7(candidate).isDirectory();
5518
+ isDirectory = statSync8(candidate).isDirectory();
4892
5519
  } catch {
4893
5520
  continue;
4894
5521
  }
@@ -4906,44 +5533,102 @@ function findKmpLayout(rootDir) {
4906
5533
 
4907
5534
  // src/engine/profile-builder.ts
4908
5535
  var RESTORE_RE = /restorePurchases|restoreCompletedTransactions|restoreTransactions|queryPurchasesAsync|\brestore\s*\(\s*\)/;
4909
- var ACCOUNT_DELETION_RE = /deleteAccount|removeAccount|deleteUser|closeAccount|account.{0,20}delet/i;
5536
+ var ACCOUNT_DELETION_RE = /\b(?:delete|remove|close|destroy|purge)(?:My)?(?:Account|User|Profile)s?(?![a-z])|\baccountDeletion\b|["'`][^"'`]{0,20}[Dd]elete (?:my )?[Aa]ccount/;
4910
5537
  var ACCOUNT_CREATION_RE = /createAccount|createUser|signUp|register(?:User|Account|WithEmail)/;
4911
5538
  var PRIVACY_POLICY_RE = /privacy[\s_-]?policy/i;
4912
5539
  var EXTERNAL_CHECKOUT_RE = /checkout\.stripe\.com|buy\.stripe\.com|paypal\.com|lemonsqueezy|gumroad|paddle\.com/i;
4913
5540
  var WEBVIEW_RE = /WKWebView|UIWebView|webview_flutter|react-native-webview|InAppWebView|android\.webkit\.WebView/;
5541
+ var UGC_SURFACE_RE = /\b(?:add|post|publish|submit|create|edit|update|delete|fetch|load|get)[A-Za-z]{0,24}(?:Comment|Reply|Thread|Discussion|Post)s?(?![a-z])|\b(?:post|publish|upload|submit|share)(?:Review|Story|Photo|Video|Content|Message)s?(?![a-z])|\buserGenerated\b|(?:[Ff]eed|[Tt]imeline|[Cc]ommunity)(?:ViewModel|Controller|Bloc|Provider|Repository|Service|Screen|Page|View)s?(?![a-z])|\bcommentCount\b|\blikeCount\b|\bupvote|\bsharedBy\b/;
5542
+ var MODERATION_RE = /\b(?:report|flag)(?!Error|Crash|Bug|Exception|Issue|Analytics|Event)[A-Za-z]{0,24}(?:Content|Post|Comment|User|Abuse|Message|Reason|Item|Set|Author)s?(?![a-z])|\b(?:block|mute|ban|unblock)(?:ed)?(?:User|Author|Account|Member|List|Comment|Set)s?(?![a-z])|(?:Block|Unblock|Mute)(?:ed)?(?:User|Author|Account|Member|List|Comment|Set)s?(?![a-z])|[Cc]ontentReport|[Rr]eportReason|\bmoderate(?:Comment|Post|Content|User|Item)|[Cc]ontentModeration\b|[Mm]oderationQueue\b/;
4914
5543
  function round2(n) {
4915
5544
  return Math.round(n * 100) / 100;
4916
5545
  }
5546
+ var ONESHOT_SIGNALS = [
5547
+ { key: "restore_purchases", re: RESTORE_RE },
5548
+ { key: "account_deletion", re: ACCOUNT_DELETION_RE },
5549
+ { key: "account_creation", re: ACCOUNT_CREATION_RE },
5550
+ { key: "privacy_policy_link", re: PRIVACY_POLICY_RE },
5551
+ { key: "external_checkout_url", re: EXTERNAL_CHECKOUT_RE }
5552
+ ];
4917
5553
  function scanSourceSignals(input) {
4918
- let restore = false;
4919
- let deletion = false;
4920
- let creation = false;
4921
- let privacyPolicy = false;
4922
- let externalCheckout = false;
5554
+ const complete = input.coverageComplete ?? true;
5555
+ const found = /* @__PURE__ */ new Map();
5556
+ const hits = [];
4923
5557
  let webviewFiles = 0;
4924
5558
  let sourceChars = 0;
4925
- for (const { content } of input.files) {
5559
+ for (const { path: path2, content } of input.files) {
4926
5560
  sourceChars += content.length;
4927
- if (!restore && RESTORE_RE.test(content)) restore = true;
4928
- if (!deletion && ACCOUNT_DELETION_RE.test(content)) deletion = true;
4929
- if (!creation && ACCOUNT_CREATION_RE.test(content)) creation = true;
4930
- if (!privacyPolicy && PRIVACY_POLICY_RE.test(content)) privacyPolicy = true;
4931
- if (!externalCheckout && EXTERNAL_CHECKOUT_RE.test(content)) externalCheckout = true;
4932
- if (WEBVIEW_RE.test(content)) webviewFiles += 1;
5561
+ for (const { key, re } of ONESHOT_SIGNALS) {
5562
+ if (found.has(key)) continue;
5563
+ const m = re.exec(content);
5564
+ if (m) {
5565
+ const hit = { path: path2, signal: key, index: m.index };
5566
+ found.set(key, hit);
5567
+ hits.push(hit);
5568
+ }
5569
+ }
5570
+ const ugc = UGC_SURFACE_RE.exec(content);
5571
+ if (ugc && !found.has("ugc_surface")) {
5572
+ const hit = { path: path2, signal: "ugc_surface", index: ugc.index };
5573
+ found.set("ugc_surface", hit);
5574
+ hits.push(hit);
5575
+ }
5576
+ if (ugc && !found.has("moderation_controls")) {
5577
+ const mod = MODERATION_RE.exec(content);
5578
+ if (mod) {
5579
+ const hit = { path: path2, signal: "moderation_controls", index: mod.index };
5580
+ found.set("moderation_controls", hit);
5581
+ hits.push(hit);
5582
+ }
5583
+ }
5584
+ const wv = WEBVIEW_RE.exec(content);
5585
+ if (wv) {
5586
+ webviewFiles += 1;
5587
+ hits.push({ path: path2, signal: "webview", index: wv.index });
5588
+ }
4933
5589
  }
4934
5590
  const scannedFiles = input.files.length;
5591
+ const tri = (key) => found.has(key) ? true : complete ? false : void 0;
5592
+ const put = (name, key) => {
5593
+ const v = tri(key);
5594
+ return v === void 0 ? {} : { [name]: v };
5595
+ };
4935
5596
  return {
4936
- restore_purchases_found: restore,
4937
- account_deletion_found: deletion,
4938
- privacy_policy_link_found: privacyPolicy,
4939
- external_checkout_url_found: externalCheckout,
4940
- webview_ratio: round2(webviewFiles / Math.max(scannedFiles, 1)),
4941
- account_creation_found: creation,
5597
+ ...put("restore_purchases_found", "restore_purchases"),
5598
+ ...put("account_deletion_found", "account_deletion"),
5599
+ ...put("account_creation_found", "account_creation"),
5600
+ ...put("privacy_policy_link_found", "privacy_policy_link"),
5601
+ ...put("external_checkout_url_found", "external_checkout_url"),
5602
+ ...put("ugc_surface_found", "ugc_surface"),
5603
+ ...put("moderation_controls_found", "moderation_controls"),
5604
+ ...complete ? { webview_ratio: round2(webviewFiles / Math.max(scannedFiles, 1)) } : {},
4942
5605
  webview_files: webviewFiles,
4943
5606
  scanned_files: scannedFiles,
4944
- source_chars: sourceChars
5607
+ source_chars: sourceChars,
5608
+ coverage_complete: complete,
5609
+ hits
4945
5610
  };
4946
5611
  }
5612
+ var SIGNAL_FACT_PATH = {
5613
+ restore_purchases: "signals.restore_purchases_found",
5614
+ account_deletion: "signals.account_deletion_found",
5615
+ account_creation: "signals.account_creation_found",
5616
+ privacy_policy_link: "signals.privacy_policy_link_found",
5617
+ external_checkout_url: "signals.external_checkout_url_found",
5618
+ ugc_surface: "signals.ugc_surface_found",
5619
+ moderation_controls: "signals.moderation_controls_found",
5620
+ webview: "signals.webview_ratio"
5621
+ };
5622
+ function signalEvidence(signals) {
5623
+ const evidence = {};
5624
+ if (!signals) return evidence;
5625
+ for (const hit of signals.hits) {
5626
+ const factPath = SIGNAL_FACT_PATH[hit.signal];
5627
+ if (!factPath || evidence[factPath]) continue;
5628
+ evidence[factPath] = { file: hit.path, detail: "source scan" };
5629
+ }
5630
+ return evidence;
5631
+ }
4947
5632
  function permissionShortName(permission) {
4948
5633
  const idx = permission.lastIndexOf(".");
4949
5634
  return idx >= 0 ? permission.slice(idx + 1) : permission;
@@ -4989,10 +5674,13 @@ function deriveCapabilities(input) {
4989
5674
  }
4990
5675
  const signals = input.signals;
4991
5676
  if (signals) {
4992
- if (signals.account_creation_found) caps.add("account_creation");
4993
- if (signals.account_deletion_found) caps.add("account_deletion");
4994
- if (signals.webview_ratio >= 0.5) caps.add("webview_heavy");
4995
- if (signals.external_checkout_url_found) caps.add("external_payment");
5677
+ if (signals.account_creation_found === true) caps.add("account_creation");
5678
+ if (signals.account_deletion_found === true) caps.add("account_deletion");
5679
+ if (signals.external_checkout_url_found === true) caps.add("external_payment");
5680
+ if (signals.ugc_surface_found === true) caps.add("ugc");
5681
+ if (signals.webview_ratio !== void 0 && signals.webview_ratio >= 0.5) {
5682
+ caps.add("webview_heavy");
5683
+ }
4996
5684
  }
4997
5685
  for (const permission of input.android?.android.permissions ?? []) {
4998
5686
  switch (permissionShortName(permission)) {
@@ -5039,6 +5727,7 @@ function deriveCapabilities(input) {
5039
5727
  function mergeEvidence(input) {
5040
5728
  const evidence = {};
5041
5729
  const sources = [
5730
+ signalEvidence(input.signals),
5042
5731
  input.ios?.evidence,
5043
5732
  input.entitlements?.evidence,
5044
5733
  input.privacyManifest?.evidence,
@@ -5064,19 +5753,28 @@ function buildAppProfile(input) {
5064
5753
  privacy_manifest: input.privacyManifest?.privacy_manifest ?? null
5065
5754
  } : null;
5066
5755
  const android = input.android ? input.android.android : null;
5067
- const signals = input.signals ? {
5068
- restore_purchases_found: input.signals.restore_purchases_found,
5069
- account_deletion_found: input.signals.account_deletion_found,
5070
- privacy_policy_link_found: input.signals.privacy_policy_link_found,
5071
- external_checkout_url_found: input.signals.external_checkout_url_found,
5072
- webview_ratio: input.signals.webview_ratio
5073
- } : {
5074
- restore_purchases_found: null,
5075
- account_deletion_found: null,
5076
- privacy_policy_link_found: null,
5077
- external_checkout_url_found: null,
5078
- webview_ratio: null
5079
- };
5756
+ const SIGNAL_FIELDS = [
5757
+ "restore_purchases_found",
5758
+ "account_deletion_found",
5759
+ "privacy_policy_link_found",
5760
+ "external_checkout_url_found",
5761
+ "ugc_surface_found",
5762
+ "moderation_controls_found",
5763
+ "webview_ratio"
5764
+ ];
5765
+ const signals = {};
5766
+ if (input.signals) {
5767
+ const src = input.signals;
5768
+ for (const field of SIGNAL_FIELDS) {
5769
+ if (Object.prototype.hasOwnProperty.call(src, field)) {
5770
+ signals[field] = src[field];
5771
+ }
5772
+ }
5773
+ } else {
5774
+ for (const field of SIGNAL_FIELDS) {
5775
+ signals[field] = null;
5776
+ }
5777
+ }
5080
5778
  return {
5081
5779
  schema_version: 1,
5082
5780
  generated_at: input.generatedAt,
@@ -5100,7 +5798,11 @@ function buildAppProfile(input) {
5100
5798
  evidence: mergeEvidence(input),
5101
5799
  stats: {
5102
5800
  files_scanned: input.signals?.scanned_files ?? 0,
5103
- source_chars_read: input.signals?.source_chars ?? 0
5801
+ source_chars_read: input.signals?.source_chars ?? 0,
5802
+ ...input.coverage ? {
5803
+ files_discovered: input.coverage.discovered,
5804
+ coverage_complete: input.coverage.complete
5805
+ } : {}
5104
5806
  }
5105
5807
  };
5106
5808
  }
@@ -5248,8 +5950,8 @@ function evaluatePredicate(profile, pred, matchedSdkIds) {
5248
5950
  }
5249
5951
  return evaluateFactPredicate(profile, pred.fact, pred.op, pred.value);
5250
5952
  }
5251
- function interpolate(text, ctx) {
5252
- return text.replace(
5953
+ function interpolate(text2, ctx) {
5954
+ return text2.replace(
5253
5955
  /\{\{(\w+)\}\}/g,
5254
5956
  (match, key) => Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : match
5255
5957
  );
@@ -5425,47 +6127,6 @@ function evaluateRules(profile, bundle) {
5425
6127
  }
5426
6128
 
5427
6129
  // src/engine/index.ts
5428
- function extensionsForStack(stack) {
5429
- switch (stack) {
5430
- case "flutter":
5431
- return [".dart"];
5432
- case "swift":
5433
- return [".swift", ".m", ".mm", ".h"];
5434
- case "kotlin":
5435
- return [".kt", ".kts"];
5436
- case "expo":
5437
- case "react-native":
5438
- case "capacitor":
5439
- return [".ts", ".tsx", ".js", ".jsx"];
5440
- case "dotnet-maui":
5441
- return [".cs", ".xaml"];
5442
- case "unity":
5443
- return [".cs"];
5444
- case "kmp":
5445
- return [".kt", ".kts", ".swift"];
5446
- default:
5447
- return [".ts", ".js", ".swift", ".dart", ".kt"];
5448
- }
5449
- }
5450
- var SELF_SCAN_MAX_FILES = 300;
5451
- var SELF_SCAN_MAX_TOTAL_CHARS = 200 * 1024;
5452
- function selfScanSource(rootDir, stack) {
5453
- const relPaths = scanDirectory(rootDir, {
5454
- extensions: extensionsForStack(stack),
5455
- maxDepth: 8,
5456
- maxFiles: SELF_SCAN_MAX_FILES
5457
- });
5458
- const files = [];
5459
- let totalChars = 0;
5460
- for (const rel of relPaths) {
5461
- if (files.length >= SELF_SCAN_MAX_FILES || totalChars >= SELF_SCAN_MAX_TOTAL_CHARS) break;
5462
- const content = readFileSafe(join14(rootDir, rel));
5463
- if (content === null) continue;
5464
- files.push({ path: rel, content });
5465
- totalChars += content.length;
5466
- }
5467
- return files;
5468
- }
5469
6130
  function runStaticEngine(input) {
5470
6131
  const { rootDir, stackResult, bundle } = input;
5471
6132
  const { stack, platforms } = stackResult;
@@ -5495,13 +6156,11 @@ function runStaticEngine(input) {
5495
6156
  extraEvidence = unityExtraction.evidence;
5496
6157
  }
5497
6158
  }
5498
- let signals;
5499
- if (input.sourceFiles !== void 0) {
5500
- signals = scanSourceSignals({ files: input.sourceFiles });
5501
- } else {
5502
- const selfFiles = selfScanSource(rootDir, stack);
5503
- signals = selfFiles.length > 0 ? scanSourceSignals({ files: selfFiles }) : null;
5504
- }
6159
+ const corpus = input.sourceCorpus ?? readSourceCorpus(rootDir, stack);
6160
+ const signals = corpus.files.length > 0 ? scanSourceSignals({
6161
+ files: corpus.files,
6162
+ coverageComplete: corpus.coverage.complete
6163
+ }) : null;
5505
6164
  const profile = buildAppProfile({
5506
6165
  rootDir,
5507
6166
  stackResult,
@@ -5515,13 +6174,20 @@ function runStaticEngine(input) {
5515
6174
  android,
5516
6175
  gradleTargets,
5517
6176
  signals,
6177
+ coverage: corpus.coverage,
5518
6178
  generatedAt: input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
5519
6179
  });
5520
6180
  if (extraEvidence) {
5521
6181
  profile.evidence = { ...profile.evidence, ...extraEvidence };
5522
6182
  }
5523
6183
  const { checks, findings } = evaluateRules(profile, bundle);
5524
- return { profile, checks, findings };
6184
+ return {
6185
+ profile,
6186
+ checks,
6187
+ findings,
6188
+ coverage: corpus.coverage,
6189
+ signalHits: signals?.hits ?? []
6190
+ };
5525
6191
  }
5526
6192
 
5527
6193
  // src/engine/remote.ts
@@ -5685,53 +6351,6 @@ function loadBundledSnapshot() {
5685
6351
  }
5686
6352
  }
5687
6353
 
5688
- // src/engine/redactor.ts
5689
- function buildSimpleRules() {
5690
- return [
5691
- {
5692
- kind: "pem",
5693
- regex: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g
5694
- },
5695
- { kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{16,}/g },
5696
- {
5697
- kind: "openai",
5698
- regex: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{32,}/g
5699
- },
5700
- { kind: "stripe", regex: /(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g },
5701
- { kind: "google", regex: /AIza[0-9A-Za-z_-]{35}/g },
5702
- { kind: "aws", regex: /AKIA[0-9A-Z]{16}/g },
5703
- { kind: "github", regex: /gh[pousr]_[A-Za-z0-9]{20,}/g },
5704
- { kind: "slack", regex: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
5705
- {
5706
- kind: "jwt",
5707
- regex: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g
5708
- }
5709
- ];
5710
- }
5711
- function buildGenericRule() {
5712
- return /(api[_-]?key|apikey|secret|token|passwd|password|client[_-]?secret)(\s*[:=]\s*)(["'])([^"'\n]{12,})(["'])/gi;
5713
- }
5714
- var ALREADY_REDACTED = /^\[REDACTED:[A-Za-z0-9_]+\]$/;
5715
- function redactSecrets(source) {
5716
- let text = source;
5717
- let redactedCount = 0;
5718
- for (const { kind, regex } of buildSimpleRules()) {
5719
- text = text.replace(regex, () => {
5720
- redactedCount++;
5721
- return `[REDACTED:${kind}]`;
5722
- });
5723
- }
5724
- text = text.replace(
5725
- buildGenericRule(),
5726
- (full, key, delim, openQuote, value, closeQuote) => {
5727
- if (ALREADY_REDACTED.test(value)) return full;
5728
- redactedCount++;
5729
- return `${key}${delim}${openQuote}[REDACTED:generic]${closeQuote}`;
5730
- }
5731
- );
5732
- return { text, redactedCount };
5733
- }
5734
-
5735
6354
  // src/review/feature-detector.ts
5736
6355
  function getFeatureDiscoveryPrompt() {
5737
6356
  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.
@@ -5958,8 +6577,10 @@ Do not include an approval estimate; the final outcome is computed deterministic
5958
6577
  - low: Improves chances but not a guaranteed rejection
5959
6578
  - info: Best practice suggestion
5960
6579
  6. Focus on the TOP issues (max 10 findings). Quality over quantity.
5961
- 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".
5962
- 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.`;
6580
+ 7. EVIDENCE IS MANDATORY. Every finding MUST set "file" to a path that appears in the Source File Inventory, and "evidence" MUST quote the relevant code verbatim. A finding with no file, or an invented file path, is discarded before the user sees it.
6581
+ 8. NEVER INFER ABSENCE. You are shown a SUBSET of the source, but the inventory lists EVERY file in the project. If a feature would live in a file you were not given, you have learned nothing about it. Say nothing. "I didn't see it in the provided code" is not evidence.
6582
+ 9. The "Resolved By Deterministic Scan" section is GROUND TRUTH, produced by regexing every file in the project. If it says a feature was FOUND, that feature exists: do not report it as missing regardless of what your excerpts show. Only "unknown" entries are open questions.
6583
+ 10. Report absence ONLY with positive contradicting evidence, e.g. subscription purchase code in a file you were shown PLUS the deterministic scan reporting restore purchases NOT FOUND.`;
5963
6584
  }
5964
6585
  function parseAIResponse(response) {
5965
6586
  try {
@@ -6029,6 +6650,53 @@ function dedupeAIFindings(aiFindings, staticFindings) {
6029
6650
  });
6030
6651
  return { kept, droppedCount: aiFindings.length - kept.length };
6031
6652
  }
6653
+ var ABSENCE_CLAIM_RE = /\b(missing|absent|no |not (?:present|implemented|found|available)|lacks?|lacking|without)\b/i;
6654
+ var REFUTABLE_CLAIMS = [
6655
+ {
6656
+ signal: "account_deletion",
6657
+ guidelines: ["5.1.1(v)", "5.1.1"],
6658
+ topic: /account\s+deletion|delet\w*\s+(?:my\s+|the\s+|their\s+)?account|remov\w*\s+(?:my\s+|the\s+)?account/i
6659
+ },
6660
+ {
6661
+ signal: "restore_purchases",
6662
+ guidelines: ["3.1.2", "3.1.1"],
6663
+ topic: /restor\w*\s+(?:in-app\s+)?purchase|purchase\s+restor|restore\s+button/i
6664
+ },
6665
+ {
6666
+ signal: "privacy_policy_link",
6667
+ guidelines: ["5.1.1", "5.1.2"],
6668
+ topic: /privacy\s+policy/i
6669
+ },
6670
+ {
6671
+ signal: "moderation_controls",
6672
+ guidelines: ["1.2"],
6673
+ topic: /moderat|report\w*\s+(?:content|posts?|comments?|users?|abuse)|block\w*\s+(?:users?|authors?)|flag\w*\s+content/i
6674
+ }
6675
+ ];
6676
+ function filterUnsupportedAIFindings(findings, opts) {
6677
+ const resolved = new Set(opts.resolvedTrue);
6678
+ const kept = [];
6679
+ let downgradedCount = 0;
6680
+ for (const finding of findings) {
6681
+ const text2 = `${finding.title ?? ""} ${finding.description ?? ""}`;
6682
+ const claimsAbsence = ABSENCE_CLAIM_RE.test(text2);
6683
+ if (claimsAbsence) {
6684
+ const guideline = finding.guideline_number ?? "";
6685
+ const refuted = REFUTABLE_CLAIMS.some(
6686
+ (claim) => resolved.has(claim.signal) && claim.guidelines.includes(guideline) && claim.topic.test(text2)
6687
+ );
6688
+ if (refuted) continue;
6689
+ if (!finding.file || !opts.corpusPaths.has(finding.file)) continue;
6690
+ }
6691
+ if (finding.risk_level === "high" && !finding.evidence?.trim()) {
6692
+ kept.push({ ...finding, risk_level: "medium" });
6693
+ downgradedCount += 1;
6694
+ continue;
6695
+ }
6696
+ kept.push(finding);
6697
+ }
6698
+ return { kept, droppedCount: findings.length - kept.length, downgradedCount };
6699
+ }
6032
6700
  var FEATURE_CAPABILITY_MAP = {
6033
6701
  push_notifications: "push_notifications",
6034
6702
  push_notification: "push_notifications",
@@ -6083,9 +6751,11 @@ function mapFeaturesToCapabilities(analysis) {
6083
6751
  }
6084
6752
  return [...caps].sort();
6085
6753
  }
6086
- function formatValueOrUnknown(value) {
6087
- if (value === null) return "unknown";
6088
- return value ? "yes" : "no";
6754
+ function formatSignalLine(label, value, profile, factPath) {
6755
+ if (value === null || value === void 0) return `- ${label}: unknown`;
6756
+ if (!value) return `- ${label}: NOT FOUND anywhere in the scanned source`;
6757
+ const file = profile.evidence[factPath]?.file;
6758
+ return `- ${label}: FOUND${file ? ` (evidence: ${file})` : ""}`;
6089
6759
  }
6090
6760
  function formatProfileDigest(profile) {
6091
6761
  const lines = [];
@@ -6123,8 +6793,67 @@ function formatProfileDigest(profile) {
6123
6793
  lines.push(`- Android permissions: ${profile.android.permissions.join(", ") || "none"}`);
6124
6794
  }
6125
6795
  const signals = profile.signals;
6796
+ const scanned = profile.stats.files_scanned;
6797
+ const discovered = profile.stats.files_discovered;
6798
+ const complete = profile.stats.coverage_complete;
6799
+ lines.push("");
6800
+ lines.push("### Resolved By Deterministic Scan (ground truth, do not contradict)");
6801
+ if (discovered !== void 0 && complete === true) {
6802
+ lines.push(
6803
+ `The scanner read ALL ${discovered} source files in this project. The results below are facts, not impressions.`
6804
+ );
6805
+ } else if (discovered !== void 0) {
6806
+ lines.push(
6807
+ `The scanner read ${scanned} of ${discovered} source files (coverage incomplete). Anything marked "unknown" was not established either way.`
6808
+ );
6809
+ }
6126
6810
  lines.push(
6127
- `- Source signals: restore purchases ${formatValueOrUnknown(signals.restore_purchases_found)}, account deletion ${formatValueOrUnknown(signals.account_deletion_found)}, privacy policy link ${formatValueOrUnknown(signals.privacy_policy_link_found)}, external checkout URL ${formatValueOrUnknown(signals.external_checkout_url_found)}`
6811
+ formatSignalLine(
6812
+ "Restore purchases",
6813
+ signals.restore_purchases_found,
6814
+ profile,
6815
+ "signals.restore_purchases_found"
6816
+ )
6817
+ );
6818
+ lines.push(
6819
+ formatSignalLine(
6820
+ "Account deletion",
6821
+ signals.account_deletion_found,
6822
+ profile,
6823
+ "signals.account_deletion_found"
6824
+ )
6825
+ );
6826
+ lines.push(
6827
+ formatSignalLine(
6828
+ "In-app privacy policy link",
6829
+ signals.privacy_policy_link_found,
6830
+ profile,
6831
+ "signals.privacy_policy_link_found"
6832
+ )
6833
+ );
6834
+ lines.push(
6835
+ formatSignalLine(
6836
+ "External checkout URL",
6837
+ signals.external_checkout_url_found,
6838
+ profile,
6839
+ "signals.external_checkout_url_found"
6840
+ )
6841
+ );
6842
+ lines.push(
6843
+ formatSignalLine(
6844
+ "User-generated content surface",
6845
+ signals.ugc_surface_found,
6846
+ profile,
6847
+ "signals.ugc_surface_found"
6848
+ )
6849
+ );
6850
+ lines.push(
6851
+ formatSignalLine(
6852
+ "Moderation controls (report/block)",
6853
+ signals.moderation_controls_found,
6854
+ profile,
6855
+ "signals.moderation_controls_found"
6856
+ )
6128
6857
  );
6129
6858
  return lines.join("\n");
6130
6859
  }
@@ -6179,6 +6908,12 @@ ${featureAnalysis.features.map((f) => `- ${f.feature}: ${f.description} (confide
6179
6908
  **Risk Areas:**
6180
6909
  ${featureAnalysis.riskAreas.map((r) => `- ${r}`).join("\n")}
6181
6910
 
6911
+ ` : "";
6912
+ const inventorySection = input.inventory && input.inventory.length > 0 ? `## Source File Inventory (${input.inventory.length} files, complete)
6913
+ Every source file in this project. Files not reproduced below still EXIST; you simply were not shown them.
6914
+
6915
+ ${input.inventory.join("\n")}
6916
+
6182
6917
  ` : "";
6183
6918
  const userPrompt = `## App Information
6184
6919
  - Platform: ${input.platform}
@@ -6198,7 +6933,7 @@ These are REAL cases where Apple rejected similar apps. Use them to calibrate:
6198
6933
 
6199
6934
  ${storiesText}
6200
6935
 
6201
- ## Source Code Context
6936
+ ${inventorySection}## Source Code Context
6202
6937
 
6203
6938
  ${input.sourceContext}
6204
6939
 
@@ -6214,6 +6949,8 @@ Now review this app as an Apple reviewer testing it on a device. Focus on behavi
6214
6949
  featuresDetected,
6215
6950
  ragStoriesUsed: relevantStories.length,
6216
6951
  dedupedCount: 0,
6952
+ unsupportedCount: 0,
6953
+ downgradedCount: 0,
6217
6954
  profile,
6218
6955
  checks,
6219
6956
  staticFindings
@@ -6221,12 +6958,24 @@ Now review this app as an Apple reviewer testing it on a device. Focus on behavi
6221
6958
  }
6222
6959
  const converted = convertFindings(parsed);
6223
6960
  const { kept, droppedCount } = dedupeAIFindings(converted, staticFindings);
6961
+ const resolvedTrue = [];
6962
+ const sig = profile.signals;
6963
+ if (sig.account_deletion_found === true) resolvedTrue.push("account_deletion");
6964
+ if (sig.restore_purchases_found === true) resolvedTrue.push("restore_purchases");
6965
+ if (sig.privacy_policy_link_found === true) resolvedTrue.push("privacy_policy_link");
6966
+ if (sig.moderation_controls_found === true) resolvedTrue.push("moderation_controls");
6967
+ const guarded = filterUnsupportedAIFindings(kept, {
6968
+ corpusPaths: new Set(input.inventory ?? []),
6969
+ resolvedTrue
6970
+ });
6224
6971
  return {
6225
- aiFindings: kept,
6972
+ aiFindings: guarded.kept,
6226
6973
  aiAssessment: parsed.overallAssessment || "AI review completed.",
6227
6974
  featuresDetected,
6228
6975
  ragStoriesUsed: relevantStories.length,
6229
6976
  dedupedCount: droppedCount,
6977
+ unsupportedCount: guarded.droppedCount,
6978
+ downgradedCount: guarded.downgradedCount,
6230
6979
  profile,
6231
6980
  checks,
6232
6981
  staticFindings
@@ -6293,6 +7042,9 @@ function assembleReportV2(input) {
6293
7042
  return {
6294
7043
  features_detected: input.featuresDetected,
6295
7044
  source_files_analyzed: input.sourceFilesAnalyzed,
7045
+ ...input.sourceFilesDiscovered !== void 0 ? { source_files_discovered: input.sourceFilesDiscovered } : {},
7046
+ ...input.sourceScanComplete !== void 0 ? { source_scan_complete: input.sourceScanComplete } : {},
7047
+ ...input.aiContextFiles !== void 0 ? { ai_context_files: input.aiContextFiles } : {},
6296
7048
  platform: input.platform,
6297
7049
  bundle_id: input.bundleId,
6298
7050
  findings,
@@ -6311,7 +7063,7 @@ function assembleReportV2(input) {
6311
7063
  }
6312
7064
 
6313
7065
  // src/review/formatters.ts
6314
- import chalk2 from "chalk";
7066
+ import chalk4 from "chalk";
6315
7067
  var RISK_ICONS = {
6316
7068
  high: "\u{1F534}",
6317
7069
  medium: "\u{1F7E1}",
@@ -6325,33 +7077,44 @@ var RISK_LABELS = {
6325
7077
  info: "INFO"
6326
7078
  };
6327
7079
  var RISK_COLORS = {
6328
- high: chalk2.red,
6329
- medium: chalk2.yellow,
6330
- low: chalk2.blue,
6331
- info: chalk2.gray
7080
+ high: chalk4.red,
7081
+ medium: chalk4.yellow,
7082
+ low: chalk4.blue,
7083
+ info: chalk4.gray
6332
7084
  };
7085
+ function formatCoverage(report) {
7086
+ const analyzed = report.source_files_analyzed;
7087
+ const discovered = report.source_files_discovered;
7088
+ if (discovered === void 0 || discovered <= 0) {
7089
+ return `${analyzed} source files analyzed`;
7090
+ }
7091
+ const pct = Math.round(analyzed / discovered * 100);
7092
+ const ai = report.ai_context_files;
7093
+ const aiPart = ai !== void 0 ? ` \xB7 ${ai} sent to AI` : "";
7094
+ return `${analyzed} of ${discovered} source files scanned (${pct}%)${aiPart}`;
7095
+ }
6333
7096
  var STATIC_ONLY_BANNER = "AI behavioral review skipped. Static rule results only.";
6334
7097
  function outcomeDisplay(outcome) {
6335
7098
  switch (outcome) {
6336
7099
  case "likely-approved":
6337
- return chalk2.green.bold("LIKELY APPROVED");
7100
+ return chalk4.green.bold("LIKELY APPROVED");
6338
7101
  case "likely-rejected":
6339
- return chalk2.red.bold("LIKELY REJECTED");
7102
+ return chalk4.red.bold("LIKELY REJECTED");
6340
7103
  case "needs-attention":
6341
- return chalk2.yellow.bold("NEEDS ATTENTION");
7104
+ return chalk4.yellow.bold("NEEDS ATTENTION");
6342
7105
  default:
6343
- return chalk2.gray(outcome);
7106
+ return chalk4.gray(outcome);
6344
7107
  }
6345
7108
  }
6346
7109
  function approvalBar(chance) {
6347
7110
  const width = 20;
6348
7111
  const filled = Math.round(chance / 100 * width);
6349
7112
  const empty = width - filled;
6350
- let color;
6351
- if (chance >= 70) color = chalk2.green;
6352
- else if (chance >= 40) color = chalk2.yellow;
6353
- else color = chalk2.red;
6354
- return color("\u2588".repeat(filled)) + chalk2.gray("\u2591".repeat(empty)) + ` ${chance}%`;
7113
+ let color2;
7114
+ if (chance >= 70) color2 = chalk4.green;
7115
+ else if (chance >= 40) color2 = chalk4.yellow;
7116
+ else color2 = chalk4.red;
7117
+ return color2("\u2588".repeat(filled)) + chalk4.gray("\u2591".repeat(empty)) + ` ${chance}%`;
6355
7118
  }
6356
7119
  function groupChecks(checks) {
6357
7120
  return {
@@ -6362,8 +7125,8 @@ function groupChecks(checks) {
6362
7125
  };
6363
7126
  }
6364
7127
  function detectionBadge(finding) {
6365
- if (finding.detection === "static") return chalk2.dim("[static]") + " ";
6366
- if (finding.detection === "ai") return chalk2.cyan("[AI]") + " ";
7128
+ if (finding.detection === "static") return chalk4.dim("[static]") + " ";
7129
+ if (finding.detection === "ai") return chalk4.cyan("[AI]") + " ";
6367
7130
  return "";
6368
7131
  }
6369
7132
  function versionFooter(report) {
@@ -6380,57 +7143,64 @@ function versionFooter(report) {
6380
7143
  function formatTerminal(report) {
6381
7144
  const lines = [];
6382
7145
  lines.push("");
6383
- lines.push(chalk2.bold(" \u{1F6E1}\uFE0F App Store Review Simulation"));
6384
- 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"));
7146
+ lines.push(chalk4.bold(" \u{1F6E1}\uFE0F App Store Review Simulation"));
7147
+ lines.push(chalk4.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"));
6385
7148
  lines.push("");
6386
- lines.push(` Platform: ${chalk2.white(report.platform)}`);
7149
+ lines.push(` Platform: ${chalk4.white(report.platform)}`);
6387
7150
  if (report.bundle_id) {
6388
- lines.push(` Bundle ID: ${chalk2.white(report.bundle_id)}`);
7151
+ lines.push(` Bundle ID: ${chalk4.white(report.bundle_id)}`);
6389
7152
  }
6390
- lines.push(` Files: ${chalk2.white(String(report.source_files_analyzed))} source files analyzed`);
6391
- lines.push(` Stories: ${chalk2.white(String(report.rag_stories_used))} rejection cases referenced`);
6392
- lines.push(` Duration: ${chalk2.white((report.scan_duration_ms / 1e3).toFixed(1) + "s")}`);
7153
+ lines.push(` Files: ${chalk4.white(formatCoverage(report))}`);
7154
+ if (report.source_scan_complete === false) {
7155
+ lines.push(
7156
+ chalk4.yellow(
7157
+ ` Partial scan. Signals that were not found are reported as unverified, not as violations.`
7158
+ )
7159
+ );
7160
+ }
7161
+ lines.push(` Stories: ${chalk4.white(String(report.rag_stories_used))} rejection cases referenced`);
7162
+ lines.push(` Duration: ${chalk4.white((report.scan_duration_ms / 1e3).toFixed(1) + "s")}`);
6393
7163
  lines.push("");
6394
7164
  lines.push(` Outcome: ${outcomeDisplay(report.summary.outcome)}`);
6395
7165
  lines.push(` Approval: ${approvalBar(report.summary.estimated_approval_chance)}`);
6396
7166
  lines.push("");
6397
7167
  const counts = [
6398
- report.summary.high_risk > 0 && chalk2.red(`${report.summary.high_risk} high risk`),
6399
- report.summary.medium_risk > 0 && chalk2.yellow(`${report.summary.medium_risk} medium`),
6400
- report.summary.low_risk > 0 && chalk2.blue(`${report.summary.low_risk} low`),
6401
- report.summary.info > 0 && chalk2.gray(`${report.summary.info} info`)
7168
+ report.summary.high_risk > 0 && chalk4.red(`${report.summary.high_risk} high risk`),
7169
+ report.summary.medium_risk > 0 && chalk4.yellow(`${report.summary.medium_risk} medium`),
7170
+ report.summary.low_risk > 0 && chalk4.blue(`${report.summary.low_risk} low`),
7171
+ report.summary.info > 0 && chalk4.gray(`${report.summary.info} info`)
6402
7172
  ].filter(Boolean);
6403
7173
  lines.push(
6404
- ` Findings: ${chalk2.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
7174
+ ` Findings: ${chalk4.bold(String(report.summary.total_findings))} total` + (counts.length > 0 ? ` (${counts.join(" \xB7 ")})` : "")
6405
7175
  );
6406
7176
  lines.push("");
6407
7177
  if (report.features_detected.length > 0) {
6408
- lines.push(` Features: ${chalk2.gray(report.features_detected.join(", "))}`);
7178
+ lines.push(` Features: ${chalk4.gray(report.features_detected.join(", "))}`);
6409
7179
  lines.push("");
6410
7180
  }
6411
7181
  if (report.summary.ai_assessment) {
6412
- 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"));
6413
- lines.push(` ${chalk2.italic(report.summary.ai_assessment)}`);
6414
- lines.push(chalk2.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7182
+ lines.push(chalk4.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"));
7183
+ lines.push(` ${chalk4.italic(report.summary.ai_assessment)}`);
7184
+ lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
6415
7185
  lines.push("");
6416
7186
  }
6417
7187
  if (report.checks && report.checks.length > 0) {
6418
7188
  const groups = groupChecks(report.checks);
6419
- lines.push(chalk2.bold(" COMPLIANCE CHECKS"));
7189
+ lines.push(chalk4.bold(" COMPLIANCE CHECKS"));
6420
7190
  lines.push(
6421
- ` ${chalk2.red(`${groups.failed.length} failed`)}, ${chalk2.yellow(`${groups.unverified.length} verify manually`)}, ${chalk2.green(`${groups.passed.length} passed`)}, ${chalk2.gray(`${groups.na.length} not applicable`)}`
7191
+ ` ${chalk4.red(`${groups.failed.length} failed`)}, ${chalk4.yellow(`${groups.unverified.length} verify manually`)}, ${chalk4.green(`${groups.passed.length} passed`)}, ${chalk4.gray(`${groups.na.length} not applicable`)}`
6422
7192
  );
6423
7193
  lines.push("");
6424
7194
  for (const check of groups.failed) {
6425
- lines.push(` ${chalk2.red(`\u2717 [${check.guideline_number}] ${check.title}`)}`);
7195
+ lines.push(` ${chalk4.red(`\u2717 [${check.guideline_number}] ${check.title}`)}`);
6426
7196
  }
6427
7197
  for (const check of groups.unverified) {
6428
- lines.push(` ${chalk2.yellow(`VERIFY: [${check.guideline_number}] ${check.title}`)}`);
7198
+ lines.push(` ${chalk4.yellow(`VERIFY: [${check.guideline_number}] ${check.title}`)}`);
6429
7199
  }
6430
7200
  if (groups.passed.length > 0) {
6431
7201
  const noun = groups.passed.length === 1 ? "check" : "checks";
6432
7202
  lines.push(
6433
- chalk2.gray(
7203
+ chalk4.gray(
6434
7204
  ` ${groups.passed.length} ${noun} passed (run with --format json for the full list)`
6435
7205
  )
6436
7206
  );
@@ -6452,38 +7222,38 @@ function formatTerminal(report) {
6452
7222
  for (const finding of findings) {
6453
7223
  const icon = RISK_ICONS[level];
6454
7224
  const label = RISK_LABELS[level];
6455
- const color = RISK_COLORS[level];
6456
- lines.push(chalk2.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
6457
- lines.push(` ${icon} ${color(label)} Guideline ${finding.guideline_number} - ${finding.guideline_name}`);
6458
- lines.push(chalk2.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
7225
+ const color2 = RISK_COLORS[level];
7226
+ lines.push(chalk4.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"));
7227
+ lines.push(` ${icon} ${color2(label)} Guideline ${finding.guideline_number} - ${finding.guideline_name}`);
7228
+ lines.push(chalk4.gray(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
6459
7229
  lines.push("");
6460
- lines.push(` ${detectionBadge(finding)}${chalk2.bold(finding.title)}`);
7230
+ lines.push(` ${detectionBadge(finding)}${chalk4.bold(finding.title)}`);
6461
7231
  lines.push(` ${finding.description}`);
6462
7232
  lines.push("");
6463
- lines.push(` ${chalk2.cyan("User Impact:")} ${finding.user_impact}`);
7233
+ lines.push(` ${chalk4.cyan("User Impact:")} ${finding.user_impact}`);
6464
7234
  if (finding.file) {
6465
- lines.push(` ${chalk2.cyan("File:")} ${finding.file}`);
7235
+ lines.push(` ${chalk4.cyan("File:")} ${finding.file}`);
6466
7236
  }
6467
- lines.push(` ${chalk2.green("Fix:")} ${finding.recommendation}`);
7237
+ lines.push(` ${chalk4.green("Fix:")} ${finding.recommendation}`);
6468
7238
  if (finding.related_story_id) {
6469
- lines.push(` ${chalk2.gray("Related case:")} ${finding.related_story_id}`);
7239
+ lines.push(` ${chalk4.gray("Related case:")} ${finding.related_story_id}`);
6470
7240
  }
6471
7241
  lines.push("");
6472
7242
  }
6473
7243
  }
6474
7244
  if (report.findings.length === 0) {
6475
- lines.push(chalk2.green(" \u2713 No significant issues found. Your app looks ready for review!"));
7245
+ lines.push(chalk4.green(" \u2713 No significant issues found. Your app looks ready for review!"));
6476
7246
  lines.push("");
6477
7247
  }
6478
- lines.push(chalk2.gray(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
7248
+ lines.push(chalk4.gray(" \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550"));
6479
7249
  if (report.ai_review_ran === false) {
6480
- lines.push(chalk2.yellow(` ${STATIC_ONLY_BANNER}`));
7250
+ lines.push(chalk4.yellow(` ${STATIC_ONLY_BANNER}`));
6481
7251
  }
6482
7252
  const footer = versionFooter(report);
6483
7253
  if (footer) {
6484
- lines.push(chalk2.gray(` ${footer}`));
7254
+ lines.push(chalk4.gray(` ${footer}`));
6485
7255
  }
6486
- lines.push(chalk2.gray(" Powered by Forvibe CLI (forvibe.app)"));
7256
+ lines.push(chalk4.gray(" Powered by Forvibe CLI (forvibe.app)"));
6487
7257
  lines.push("");
6488
7258
  return lines.join("\n");
6489
7259
  }
@@ -6498,7 +7268,12 @@ function formatMarkdown(report) {
6498
7268
  if (report.bundle_id) {
6499
7269
  lines.push(`**Bundle ID:** ${report.bundle_id}`);
6500
7270
  }
6501
- lines.push(`**Files Analyzed:** ${report.source_files_analyzed}`);
7271
+ lines.push(`**Files Analyzed:** ${formatCoverage(report)}`);
7272
+ if (report.source_scan_complete === false) {
7273
+ lines.push(
7274
+ "> Partial scan. Signals that were not found are reported as unverified, not as violations."
7275
+ );
7276
+ }
6502
7277
  lines.push(`**Rejection Cases Referenced:** ${report.rag_stories_used}`);
6503
7278
  lines.push(`**Scan Duration:** ${(report.scan_duration_ms / 1e3).toFixed(1)}s`);
6504
7279
  lines.push("");
@@ -6594,21 +7369,6 @@ function appendMarkdownFooter(lines, report) {
6594
7369
  lines.push("");
6595
7370
  }
6596
7371
 
6597
- // src/utils/version.ts
6598
- import { createRequire } from "module";
6599
- var FALLBACK_VERSION = "0.0.0";
6600
- function getCliVersion() {
6601
- const require2 = createRequire(import.meta.url);
6602
- for (const candidate of ["../package.json", "../../package.json"]) {
6603
- try {
6604
- const pkg = require2(candidate);
6605
- if (pkg?.version && pkg.name === "@forvibe/cli") return pkg.version;
6606
- } catch {
6607
- }
6608
- }
6609
- return FALLBACK_VERSION;
6610
- }
6611
-
6612
7372
  // src/commands/review.ts
6613
7373
  var DEFAULT_RULEPACK_BASE_URL = "https://forvibe.app";
6614
7374
  function askQuestion2(question) {
@@ -6631,26 +7391,26 @@ async function reviewCommand(options) {
6631
7391
  const startTime = Date.now();
6632
7392
  console.log();
6633
7393
  console.log(
6634
- chalk3.bold(" Forvibe CLI") + chalk3.gray(" - App Store Review Simulation") + (deep ? chalk3.cyan(" [DEEP MODE]") : "") + (staticOnly ? chalk3.cyan(" [STATIC ONLY]") : "")
7394
+ chalk5.bold(" Forvibe CLI") + chalk5.gray(" - App Store Review Simulation") + (deep ? chalk5.cyan(" [DEEP MODE]") : "") + (staticOnly ? chalk5.cyan(" [STATIC ONLY]") : "")
6635
7395
  );
6636
7396
  console.log();
6637
- const techSpinner = ora2({
7397
+ const techSpinner = ora4({
6638
7398
  text: "Detecting tech stack...",
6639
7399
  prefixText: " "
6640
7400
  }).start();
6641
7401
  const techStack = detectTechStack(rootDir);
6642
7402
  if (techStack.stack === "unknown") {
6643
7403
  techSpinner.fail(
6644
- chalk3.red(
7404
+ chalk5.red(
6645
7405
  "No supported project found. Supported: Swift, Objective-C, Flutter, React Native, Kotlin, Kotlin Multiplatform, Capacitor, .NET MAUI, Unity"
6646
7406
  )
6647
7407
  );
6648
7408
  process.exit(1);
6649
7409
  }
6650
7410
  techSpinner.succeed(
6651
- `Tech stack: ${chalk3.bold(techStack.label)} ${chalk3.gray(`(${techStack.platforms.join(", ")})`)}`
7411
+ `Tech stack: ${chalk5.bold(techStack.label)} ${chalk5.gray(`(${techStack.platforms.join(", ")})`)}`
6652
7412
  );
6653
- const configSpinner = ora2({
7413
+ const configSpinner = ora4({
6654
7414
  text: "Reading project configuration...",
6655
7415
  prefixText: " "
6656
7416
  }).start();
@@ -6658,9 +7418,9 @@ async function reviewCommand(options) {
6658
7418
  const appName = config.app_name || "Unknown App";
6659
7419
  const bundleId = config.bundle_id || "";
6660
7420
  configSpinner.succeed(
6661
- `Config: ${chalk3.white(appName)} ${bundleId ? chalk3.gray(`(${bundleId})`) : ""}`
7421
+ `Config: ${chalk5.white(appName)} ${bundleId ? chalk5.gray(`(${bundleId})`) : ""}`
6662
7422
  );
6663
- const rulepackSpinner = ora2({
7423
+ const rulepackSpinner = ora4({
6664
7424
  text: "Loading review rulepack...",
6665
7425
  prefixText: " "
6666
7426
  }).start();
@@ -6670,27 +7430,32 @@ async function reviewCommand(options) {
6670
7430
  loadedRulepack = await loadRulepackBundle({ baseUrl: rulepackBaseUrl });
6671
7431
  } catch (error) {
6672
7432
  rulepackSpinner.fail(
6673
- chalk3.red(error instanceof Error ? error.message : "Failed to load rulepack")
7433
+ chalk5.red(error instanceof Error ? error.message : "Failed to load rulepack")
6674
7434
  );
6675
7435
  process.exit(1);
6676
7436
  }
6677
7437
  const { bundle } = loadedRulepack;
6678
7438
  rulepackSpinner.succeed(
6679
- `Rulepack ${chalk3.bold(`v${loadedRulepack.version}`)} ${chalk3.gray(`(${loadedRulepack.source})`)}`
7439
+ `Rulepack ${chalk5.bold(`v${loadedRulepack.version}`)} ${chalk5.gray(`(${loadedRulepack.source})`)}`
6680
7440
  );
6681
- const sourceSpinner = ora2({
7441
+ const sourceSpinner = ora4({
6682
7442
  text: "Reading source code...",
6683
7443
  prefixText: " "
6684
7444
  }).start();
6685
- const maxChars = deep ? 4e5 : 15e4;
6686
- const sourceFiles = readSourceFiles(rootDir, techStack.stack, maxChars);
6687
- const sourceCode = joinSourceFiles(sourceFiles);
6688
- const projectTree = generateProjectTree(rootDir);
6689
- const sourceLines = sourceCode.split("\n").length;
7445
+ const corpus = readSourceCorpus(rootDir, techStack.stack);
7446
+ const projectTree = generateProjectTree(rootDir, 5, 600);
7447
+ const cov = corpus.coverage;
6690
7448
  sourceSpinner.succeed(
6691
- `Source code: ${chalk3.bold(String(sourceLines))} lines in ${chalk3.bold(String(sourceFiles.length))} files`
7449
+ `Source: ${chalk5.bold(String(cov.read))} of ${chalk5.bold(String(cov.discovered))} files scanned ` + chalk5.gray(`(${(cov.total_bytes / 1024 / 1024).toFixed(1)} MB)`)
6692
7450
  );
6693
- const staticSpinner = ora2({
7451
+ if (!cov.complete) {
7452
+ console.log(
7453
+ chalk5.yellow(
7454
+ ` Partial scan (${cov.limits_hit.join(", ")}). Signals that were not found are reported as unverified, not as violations.`
7455
+ )
7456
+ );
7457
+ }
7458
+ const staticSpinner = ora4({
6694
7459
  text: "Running static rule engine...",
6695
7460
  prefixText: " "
6696
7461
  }).start();
@@ -6705,58 +7470,66 @@ async function reviewCommand(options) {
6705
7470
  min_android_sdk: config.min_android_sdk
6706
7471
  },
6707
7472
  bundle,
6708
- sourceFiles
7473
+ sourceCorpus: corpus
6709
7474
  });
7475
+ const maxChars = deep ? 4e5 : 15e4;
7476
+ const aiCorpus = selectAiCorpus(
7477
+ corpus,
7478
+ staticResult.signalHits,
7479
+ techStack.stack,
7480
+ maxChars
7481
+ );
7482
+ const sourceCode = joinSourceFiles(aiCorpus.files);
6710
7483
  const checkCount = (status) => staticResult.checks.filter((c) => c.status === status).length;
6711
7484
  staticSpinner.succeed(
6712
- `Static scan: ${chalk3.red(`${checkCount("fail")} failed`)}, ${chalk3.yellow(`${checkCount("unverified")} unverified`)}, ${chalk3.green(`${checkCount("pass")} passed`)}, ${chalk3.gray(`${checkCount("na")} n/a`)}`
7485
+ `Static scan: ${chalk5.red(`${checkCount("fail")} failed`)}, ${chalk5.yellow(`${checkCount("unverified")} unverified`)}, ${chalk5.green(`${checkCount("pass")} passed`)}, ${chalk5.gray(`${checkCount("na")} n/a`)}`
6713
7486
  );
6714
7487
  let provider = null;
6715
7488
  if (!staticOnly) {
6716
- const availableProviders = getAvailableProviders(deep);
7489
+ const availableProviders = getAvailableProviders();
6717
7490
  if (availableProviders.length === 0) {
6718
7491
  console.log();
6719
7492
  console.log(
6720
- chalk3.yellow(
7493
+ chalk5.yellow(
6721
7494
  " No AI API key found. AI behavioral review skipped. Static rule results below."
6722
7495
  )
6723
7496
  );
6724
- console.log(chalk3.gray(" To enable the AI review pass, set one of the following:"));
7497
+ console.log(chalk5.gray(" To enable the AI review pass, set one of the following:"));
6725
7498
  console.log(
6726
- chalk3.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk3.gray(" https://console.anthropic.com/settings/keys") + chalk3.green(" (recommended)")
7499
+ chalk5.cyan(" export ANTHROPIC_API_KEY=your-key") + chalk5.gray(" https://console.anthropic.com/settings/keys") + chalk5.green(" (recommended)")
6727
7500
  );
6728
7501
  console.log(
6729
- chalk3.cyan(" export OPENAI_API_KEY=your-key") + chalk3.gray(" https://platform.openai.com/api-keys")
7502
+ chalk5.cyan(" export OPENAI_API_KEY=your-key") + chalk5.gray(" https://platform.openai.com/api-keys")
6730
7503
  );
6731
7504
  console.log(
6732
- chalk3.cyan(" export GEMINI_API_KEY=your-key") + chalk3.gray(" https://aistudio.google.com/apikey")
7505
+ chalk5.cyan(" export GEMINI_API_KEY=your-key") + chalk5.gray(" https://aistudio.google.com/apikey")
6733
7506
  );
6734
7507
  console.log();
6735
7508
  } else if (availableProviders.length === 1) {
6736
7509
  provider = availableProviders[0].create();
6737
7510
  } else {
6738
7511
  console.log();
6739
- console.log(chalk3.white(" Multiple AI API keys detected. Choose a provider:\n"));
7512
+ console.log(chalk5.white(" Multiple AI API keys detected. Choose a provider:\n"));
6740
7513
  availableProviders.forEach((p, i) => {
6741
- const label = `${i + 1}. ${p.name}${p.recommended ? chalk3.green(" (recommended)") : ""}`;
7514
+ const label = `${i + 1}. ${p.name}${p.recommended ? chalk5.green(" (recommended)") : ""}`;
6742
7515
  console.log(` ${label}`);
6743
7516
  });
6744
7517
  console.log();
6745
7518
  const answer = await askQuestion2(
6746
- chalk3.cyan(` Enter choice (1-${availableProviders.length}): `)
7519
+ chalk5.cyan(` Enter choice (1-${availableProviders.length}): `)
6747
7520
  );
6748
7521
  const index = parseInt(answer, 10) - 1;
6749
7522
  if (isNaN(index) || index < 0 || index >= availableProviders.length) {
6750
7523
  const recommended = availableProviders.find((p) => p.recommended) || availableProviders[0];
6751
7524
  provider = recommended.create();
6752
- console.log(chalk3.gray(` Using ${recommended.name} (default)
7525
+ console.log(chalk5.gray(` Using ${recommended.name} (default)
6753
7526
  `));
6754
7527
  } else {
6755
7528
  provider = availableProviders[index].create();
6756
7529
  }
6757
7530
  }
6758
7531
  if (provider) {
6759
- console.log(chalk3.gray(` AI Provider: ${provider.name} \u2713`));
7532
+ console.log(chalk5.gray(` AI Provider: ${provider.name} \u2713`));
6760
7533
  console.log();
6761
7534
  }
6762
7535
  }
@@ -6765,7 +7538,10 @@ async function reviewCommand(options) {
6765
7538
  report = assembleReportV2({
6766
7539
  platform: techStack.label,
6767
7540
  bundleId: config.bundle_id,
6768
- sourceFilesAnalyzed: sourceFiles.length,
7541
+ sourceFilesAnalyzed: cov.read,
7542
+ sourceFilesDiscovered: cov.discovered,
7543
+ sourceScanComplete: cov.complete,
7544
+ aiContextFiles: aiCorpus.files.length,
6769
7545
  featuresDetected: [],
6770
7546
  staticFindings: staticResult.findings,
6771
7547
  aiFindings: [],
@@ -6790,13 +7566,13 @@ async function reviewCommand(options) {
6790
7566
  const { text: redactedSource, redactedCount } = redactSecrets(enrichedSource);
6791
7567
  if (redactedCount > 0) {
6792
7568
  console.log(
6793
- chalk3.yellow(
7569
+ chalk5.yellow(
6794
7570
  ` Redacted ${redactedCount} secret-like values before sending source to the AI provider.`
6795
7571
  )
6796
7572
  );
6797
7573
  console.log();
6798
7574
  }
6799
- const reviewSpinner = ora2({
7575
+ const reviewSpinner = ora4({
6800
7576
  text: deep ? `Running deep review with ${provider.name} (Pass 1: feature detection, Pass 2: Apple reviewer simulation)...` : `Running review with ${provider.name} (simulating Apple's review process)...`,
6801
7577
  prefixText: " "
6802
7578
  }).start();
@@ -6811,12 +7587,16 @@ async function reviewCommand(options) {
6811
7587
  profile: staticResult.profile,
6812
7588
  staticChecks: staticResult.checks,
6813
7589
  staticFindings: staticResult.findings,
6814
- bundle
7590
+ bundle,
7591
+ inventory: aiCorpus.inventory
6815
7592
  });
6816
7593
  report = assembleReportV2({
6817
7594
  platform: techStack.label,
6818
7595
  bundleId: config.bundle_id,
6819
- sourceFilesAnalyzed: sourceFiles.length,
7596
+ sourceFilesAnalyzed: cov.read,
7597
+ sourceFilesDiscovered: cov.discovered,
7598
+ sourceScanComplete: cov.complete,
7599
+ aiContextFiles: aiCorpus.files.length,
6820
7600
  featuresDetected: aiResult.featuresDetected,
6821
7601
  staticFindings: aiResult.staticFindings,
6822
7602
  aiFindings: aiResult.aiFindings,
@@ -6834,20 +7614,34 @@ async function reviewCommand(options) {
6834
7614
  scanDurationMs: Date.now() - startTime
6835
7615
  });
6836
7616
  reviewSpinner.succeed(
6837
- chalk3.green(
7617
+ chalk5.green(
6838
7618
  `Review complete! ${report.summary.total_findings} findings (${report.summary.high_risk} high risk)`
6839
7619
  )
6840
7620
  );
6841
7621
  if (aiResult.dedupedCount > 0) {
6842
7622
  console.log(
6843
- chalk3.gray(
7623
+ chalk5.gray(
6844
7624
  ` Deduplicated ${aiResult.dedupedCount} findings already covered by static analysis.`
6845
7625
  )
6846
7626
  );
6847
7627
  }
7628
+ if (aiResult.unsupportedCount > 0) {
7629
+ console.log(
7630
+ chalk5.gray(
7631
+ ` Discarded ${aiResult.unsupportedCount} AI findings that claimed a feature was missing without supporting evidence.`
7632
+ )
7633
+ );
7634
+ }
7635
+ if (aiResult.downgradedCount > 0) {
7636
+ console.log(
7637
+ chalk5.gray(
7638
+ ` Downgraded ${aiResult.downgradedCount} high-risk AI findings that carried no code evidence.`
7639
+ )
7640
+ );
7641
+ }
6848
7642
  } catch (error) {
6849
7643
  reviewSpinner.fail(
6850
- chalk3.red(
7644
+ chalk5.red(
6851
7645
  `Review failed: ${error instanceof Error ? error.message : "Unknown error"}`
6852
7646
  )
6853
7647
  );
@@ -6868,49 +7662,49 @@ async function reviewCommand(options) {
6868
7662
  }
6869
7663
  if (options.send) {
6870
7664
  console.log(
6871
- 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")
7665
+ chalk5.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")
6872
7666
  );
6873
7667
  console.log();
6874
7668
  const otcCode = await askQuestion2(
6875
- chalk3.cyan(" \u{1F517} Enter your Forvibe connection code: ")
7669
+ chalk5.cyan(" \u{1F517} Enter your Forvibe connection code: ")
6876
7670
  );
6877
7671
  if (!otcCode || otcCode.length < 6) {
6878
7672
  console.log(
6879
- chalk3.red(
7673
+ chalk5.red(
6880
7674
  "\n \u2717 Invalid code. Please enter the 6-character code from forvibe.app\n"
6881
7675
  )
6882
7676
  );
6883
7677
  process.exit(1);
6884
7678
  }
6885
- const connectSpinner = ora2({
7679
+ const connectSpinner = ora4({
6886
7680
  text: "Connecting to Forvibe...",
6887
7681
  prefixText: " "
6888
7682
  }).start();
6889
7683
  const client = new ForvibeClient(options.apiUrl);
6890
7684
  try {
6891
7685
  await client.validateOTC(otcCode);
6892
- connectSpinner.succeed(chalk3.green("Connected to Forvibe!"));
7686
+ connectSpinner.succeed(chalk5.green("Connected to Forvibe!"));
6893
7687
  } catch (error) {
6894
7688
  connectSpinner.fail(
6895
- chalk3.red(error instanceof Error ? error.message : "Connection failed")
7689
+ chalk5.red(error instanceof Error ? error.message : "Connection failed")
6896
7690
  );
6897
7691
  process.exit(1);
6898
7692
  }
6899
- const sendSpinner = ora2({
7693
+ const sendSpinner = ora4({
6900
7694
  text: "Sending review results to Forvibe...",
6901
7695
  prefixText: " "
6902
7696
  }).start();
6903
7697
  try {
6904
7698
  const result = await client.submitCodebaseReview(report);
6905
- sendSpinner.succeed(chalk3.green("Review results sent!"));
7699
+ sendSpinner.succeed(chalk5.green("Review results sent!"));
6906
7700
  console.log();
6907
7701
  console.log(
6908
- chalk3.bold(" \u{1F310} View combined review at: ") + chalk3.cyan.underline(result.web_url)
7702
+ chalk5.bold(" \u{1F310} View combined review at: ") + chalk5.cyan.underline(result.web_url)
6909
7703
  );
6910
7704
  console.log();
6911
7705
  } catch (error) {
6912
7706
  sendSpinner.fail(
6913
- chalk3.red(
7707
+ chalk5.red(
6914
7708
  `Failed to send: ${error instanceof Error ? error.message : "Unknown error"}`
6915
7709
  )
6916
7710
  );
@@ -6946,6 +7740,13 @@ program.command("analyze", { isDefault: true }).description(
6946
7740
  "Forvibe API URL (for development)",
6947
7741
  void 0
6948
7742
  ).action(analyzeCommand);
7743
+ program.command("screenshots").description(
7744
+ "Send your app's features, brand style, icon, and screenshots to Forvibe Screenshot Studio (use the code from Continue with Autopilot)"
7745
+ ).option("-d, --dir <path>", "Project directory to analyze", process.cwd()).option(
7746
+ "--api-url <url>",
7747
+ "Forvibe API URL (for development)",
7748
+ void 0
7749
+ ).action(screenshotsCommand);
6949
7750
  program.command("review").description(
6950
7751
  "Simulate Apple's App Store review process on your project"
6951
7752
  ).option("-d, --dir <path>", "Project directory to review", process.cwd()).option("--deep", "Enable deep 2-pass review (feature detection + reviewer simulation)").option("--format <type>", "Output format: terminal, json, markdown", "terminal").option("--static-only", "Run only the static rule engine, skipping the AI behavioral review (no API key needed)").option("--send", "Send results to Forvibe for combined review with metadata analysis").option(