// `storebound scan` engine: detect the project, run the rejection-risk rules, propose the declarations. Offline and // read-only (the only write is `--write`, done by the command, never here). import { pgsFindings, readPgsProject } from '../game-services'; import { withoutTemplate } from '../template-answers'; import { existsSync, readFileSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config'; import { reviewDemoAccount } from '../credentials'; import { AI_FIX_RULES, scanFixAction } from '../fix-actions'; import { proposeDeclarations, type Proposal } from './declare'; import { detectProject, ECO_LABEL, type ProjectFacts, type Stack, where } from './project'; import type { OriginalityReport } from '../originality'; import { APPLE_GUIDELINES, applyIgnore, type ScanFinding, scanRules, type Severity, sortScan, unusedIgnores } from './rules'; import type { SdkCategory } from './sdks'; export interface ScanReport { root: string; config: string | null; configError: string | null; stacks: Stack[]; platforms: { ios: boolean; android: boolean }; sdks: { id: string; name: string; category: SdkCategory; tracking: boolean; deps: { eco: string; name: string; version: string | null; file: string; line: number }[] }[]; permissions: { key: string; value: string; file: string; line: number }[]; android: { targetSdk: number | null; targetSdkAt: string | null; permissions: string[] }; findings: ScanFinding[]; counts: Record & { accepted: number }; declarations: Proposal; scanned: number; notes: string[]; /** `scan --originality`: the 4.3 / repetitive content comparison with the owner's other apps. */ originality?: { compared: number; flagged: number; threshold: number; pairs: { app: string; folder: string; overall: number; flagged: boolean }[] } | { error: string }; } export interface ScanRun { report: ScanReport; facts: ProjectFacts; config: ShipConfig | null; configPath: string | null } /** Find the project's config: --config, else storebound.config.* in `dir` (no upward search: a fixture or a * sub-project must not pick up a parent's config). */ async function findConfig(dir: string, explicit?: string): Promise<{ config: ShipConfig | null; path: string | null; error: string | null }> { const path = explicit ? resolve(dir, explicit) : CONFIG_NAMES.map(n => join(dir, n)).find(existsSync); if (!path) return { config: null, path: null, error: null }; try { return { config: (await loadConfig(path)).config, path, error: null }; } catch (e) { return { config: null, path, error: (e as Error).message.split('\n').slice(0, 4).join('; ') }; } } export async function runScan(dir: string, o: { config?: string } = {}): Promise { const facts = detectProject(dir); const { config, path: configPath, error } = await findConfig(dir, o.config); const rel = (p: string) => { const r = relative(dir, p); return r && !r.startsWith('..') ? r : p; }; const readRel = (p: string | undefined): { path: string; text: string } | undefined => { if (!p) return undefined; const abs = join(dir, p); try { return existsSync(abs) ? { path: rel(abs), text: readFileSync(abs, 'utf8') } : undefined; } catch { return undefined; } }; const listings = { apple: (config ? config.apple : true) ? readRel(config?.apple?.listing ?? 'store/appstore.md') : undefined, play: (config ? config.play : true) ? readRel(config?.play?.listing ?? 'store/listing.md') : undefined, captions: readRel(config?.shots?.copy ?? 'store/shots/copy.json'), }; const configFile = configPath ? { path: rel(configPath), text: readFileSync(configPath, 'utf8') } : null; const declarations = proposeDeclarations(facts, config ? withoutTemplate(config) : null, configFile); const dataMerges = config?.dataSafety ? declarations.diff.filter(d => d.change === 'merge' && d.before).map(d => { const b = d.before!; const missing = [...d.after.collect.filter(x => !b.collect.includes(x)).map(x => `collected for ${x}`), ...(d.after.shared && !b.shared ? ['shared'] : []), ...(d.after.share ?? []).filter(x => !(b.share ?? []).includes(x)).map(x => `shared for ${x}`)]; return { type: d.type, missing: [...new Set(missing)].join(', '), evidence: d.evidence }; }).filter(d => d.missing) : []; const findings = scanRules({ facts, config, configFile, listings, demoAccount: !!reviewDemoAccount(dir), labelGaps: declarations.labelGaps, dataMerges }); // Play Games Services: the Gradle dependency without the APP_ID meta-data / resource fails sign-in at runtime if (facts.android) { try { const pgs = readPgsProject(dir); for (const g of pgsFindings(config ?? {}, pgs).filter(x => x.level === 'error' && !(config?.scan?.ignore ?? []).some(i => i.rule === x.id))) findings.push({ rule: g.id, guideline: 'Play Games Services setup', store: 'play', severity: 'high', title: g.msg, evidence: [{ file: pgs.manifest?.file ?? pgs.gradle[0] ?? 'android', line: 1, text: pgs.manifest?.value ?? 'play-services-games-v2' }], fix: g.fix ?? '', skill: 'game-services', ref: 'https://developer.android.com/games/pgs/android/android-start' }); } catch { /* unreadable Android project */ } } for (const f of findings) { const action = f.accepted ? undefined : scanFixAction(f.rule, { configPath, declarationsChanged: declarations.changed }); if (action) f.action = action; else if (!f.accepted && AI_FIX_RULES.has(f.rule)) f.aiFix = true; } const counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 }; for (const f of findings) { if (f.accepted) counts.accepted++; else counts[f.severity]++; } const notes: string[] = []; if (facts.android && facts.androidInfo.mergedManifest) notes.push(`Android: permissions include the merged manifest of the last local build (${facts.androidInfo.mergedManifest}); rebuild after dependency changes.`); else if (facts.android && facts.androidInfo.manifests.length) notes.push('Android: libraries add permissions when the manifest is merged and no local build output (build/intermediates) was found; check the merged manifest (Android Studio > Merged Manifest, or the AAB) too.'); if (facts.ios && !facts.plistSources.length) notes.push('iOS: no Info.plist settings found (generated at build time?): purpose string, ATS and export compliance checks were skipped.'); if (!facts.sources.length) notes.push('No app source files found: code checks (sign-in, deletion, restore, ATT prompt) could not run.'); // scan.ignore entries that accept nothing (a renamed title, a typo, a fixed issue) are said, never silently dropped notes.push(...unusedIgnores(findings, config?.scan?.ignore ?? [])); const report: ScanReport = { root: dir, config: configPath ? rel(configPath) : null, configError: error, stacks: facts.stacks, platforms: { ios: facts.ios, android: facts.android }, sdks: facts.sdks.map(s => ({ id: s.sdk.id, name: s.sdk.name, category: s.sdk.category, tracking: s.sdk.tracking, deps: s.deps.map(d => ({ eco: d.eco, name: d.name, version: d.version, file: d.file, line: d.line })) })), permissions: [...facts.plist.values()].filter(e => /UsageDescription$/.test(e.key)).map(e => ({ key: e.key, value: String(e.value ?? ''), file: e.at.file, line: e.at.line })), android: { targetSdk: facts.androidInfo.targetSdk?.value ?? null, targetSdkAt: facts.androidInfo.targetSdk ? where(facts.androidInfo.targetSdk.at) : null, permissions: [...new Set(facts.androidInfo.permissions.map(x => x.name.replace(/^android\.permission\./, '')))] }, findings, counts, declarations, scanned: facts.scanned, notes, }; return { report, facts, config, configPath }; } const TAG: Record = { blocker: 'BLOCKER', high: 'HIGH ', medium: 'MEDIUM ', low: 'low ' }; const STACK: Record = { 'ios-native': 'iOS (native)', 'android-native': 'Android (native)', 'react-native': 'React Native', expo: 'Expo', capacitor: 'Capacitor', cordova: 'Cordova', flutter: 'Flutter', unity: 'Unity', godot: 'Godot', web: 'web' }; /** The report as plain text (the terminal output of `storebound scan`). */ export function formatScan(r: ScanReport, o: { declarations?: boolean } = {}): string { const L: string[] = []; L.push(`storebound scan · ${r.config ? r.config : 'no storebound.config.ts (project files only)'}`); if (r.configError) L.push(`config not loaded: ${r.configError}`); L.push(`stack: ${r.stacks.map(s => STACK[s]).join(', ') || 'unknown'} · targets: ${[r.platforms.ios && 'iOS', r.platforms.android && 'Android'].filter(Boolean).join(', ') || 'none found'} · ${r.scanned} files`); if (r.sdks.length) { L.push('', 'SDKs'); for (const s of r.sdks) { const d = s.deps[0]!; L.push(` ${s.name.padEnd(30)} ${s.category}${s.tracking ? ', tracking-capable' : ''} · ${ECO_LABEL[d.eco as keyof typeof ECO_LABEL] ?? d.eco} ${d.name}${d.version ? ` ${d.version}` : ''} (${d.file}:${d.line})${s.deps.length > 1 ? ` +${s.deps.length - 1}` : ''}`); } } if (r.android.targetSdk !== null) L.push(`Android targetSdk ${r.android.targetSdk} (${r.android.targetSdkAt})`); const open = r.findings.filter(f => !f.accepted); L.push('', `Rejection risks: ${r.counts.blocker} blocker, ${r.counts.high} high, ${r.counts.medium} medium, ${r.counts.low} low${r.counts.accepted ? ` (+${r.counts.accepted} accepted by the owner)` : ''}`); if (!open.length) L.push(' none found in the project files and config'); r.findings.forEach((f, i) => { L.push(`${String(i + 1).padStart(3)}. ${f.accepted ? 'accepted' : TAG[f.severity]} ${f.guideline} · ${f.store === 'apple' ? 'App Store' : 'Google Play'} · ${f.title}`); for (const e of f.evidence.slice(0, 3)) L.push(` at ${e.file}:${e.line}${e.text ? ` ${e.text}` : ''}`); if (f.accepted) L.push(` owner: ${f.accepted}`); else L.push(` fix: ${f.fix}`, ` skill: ${f.skill} · ${f.ref}`); }); if (o.declarations !== false) { const d = r.declarations; L.push('', 'Declarations proposal (detected = proven by a file; ask owner = only the owner knows)'); const areas: [string, string][] = [['dataSafety', 'Data safety'], ['appPrivacy', 'App Privacy'], ['att', 'App Tracking Transparency'], ['privacyManifest', 'Privacy manifest']]; for (const [a, label] of areas) { const items = d.items.filter(x => x.area === a); if (!items.length) continue; L.push(` ${label}`); for (const x of items) L.push(` ${x.status === 'detected' ? 'detected ' : 'ask owner'} ${x.what}: ${x.value}${x.evidence[0] ? ` (${x.evidence[0].file}:${x.evidence[0].line})` : ''}${x.note ? ` · ${x.note}` : ''}`); } const changes = d.diff.filter(x => x.change === 'add' || x.change === 'merge'); if (changes.length || d.appPrivacyChange || d.appPrivacyTypes || d.accountCreationChange) { L.push('', ` Diff against ${r.config ?? 'the config'} (written only with --write):`); for (const x of changes) { if (x.change === 'add') L.push(` + dataSafety.data.${x.type}: { shared: ${x.after.shared}, collect: [${x.after.collect.join(', ')}]${x.after.share?.length ? `, share: [${x.after.share.join(', ')}]` : ''} }`); else L.push(` ~ dataSafety.data.${x.type}: collect [${x.before!.collect.join(', ')}] -> [${x.after.collect.join(', ')}]${x.after.shared !== x.before!.shared ? `, shared ${x.before!.shared} -> ${x.after.shared}` : ''}${(x.after.share ?? []).join() !== (x.before!.share ?? []).join() ? `, share [${(x.before!.share ?? []).join(', ')}] -> [${(x.after.share ?? []).join(', ')}]` : ''}`); } if (d.accountCreationChange) L.push(` ~ dataSafety.accountCreation: ${d.accountCreationChange.from ? JSON.stringify(d.accountCreationChange.from) : 'unset'} -> ${JSON.stringify(d.accountCreationChange.to)}`); if (d.appPrivacyChange) L.push(` ~ appPrivacy.tracking: ${d.appPrivacyChange.from ?? 'unset'} -> ${d.appPrivacyChange.to}`); for (const [id, a] of Object.entries(d.appPrivacyTypes ?? {})) L.push(` + appPrivacy.types.${id}: { collected: true, purposes: [${a!.purposes!.join(', ')}] } (linked / tracking: ask owner)`); } else if (r.config) L.push('', ` No changes to ${r.config}: the dataSafety / appPrivacy blocks already cover what was detected.`); } if (r.originality) { if ('error' in r.originality) L.push('', `Originality (4.3): not run: ${r.originality.error}`); else L.push('', `Originality (4.3 / Play repetitive content): ${r.originality.compared} app(s) compared, ${r.originality.flagged} near-duplicate(s) at ${r.originality.threshold}${r.originality.pairs.length ? ` · ${r.originality.pairs.map(p => `${p.app} ${Math.round(p.overall * 100)}%`).join(', ')}` : ''}`); } if (r.notes.length) L.push('', ...r.notes.map(n => `note: ${n}`)); L.push('', 'Checked at review, not by scan: crashes and bugs on the reviewer\'s device, broken links (storebound validate checks the URLs), screenshots that do not match the app, minimum functionality and spam judgement (4.2, 4.3: scan --originality compares your own apps), local law (5.2). See the review-rejections skill.'); return L.join('\n'); } /** * `scan --originality`: the near-duplicates of an originality report as scan findings (App Store 4.3 spam, Google * Play repetitive content), owner-accepted through `scan.ignore` like every rule (rule id "originality"). Pure. */ export function addOriginality(r: ScanReport, o: OriginalityReport, stores: { apple: boolean; play: boolean }, config: ShipConfig | null, rel: (p: string) => string): void { const ignore = config?.scan?.ignore ?? []; for (const p of o.pairs.filter(x => x.flagged)) { const evidence = [{ file: rel(p.other.dir) || '.', line: 1, text: `${p.other.name}: ${Math.round(p.overall * 100)}% overall (${p.reasons.slice(0, 2).join('; ')})` }]; const fix = `${p.suggestions.slice(0, 2).join(' ') || 'Make the app different in mechanic, content and listing.'} Run storebound originality --against ${rel(p.other.dir) || '.'} for every axis; the portfolio-originality skill has the checklist.`; const base = { rule: 'originality', severity: 'high' as const, title: `Near-duplicate of your app ${p.other.name} (${Math.round(p.overall * 100)}% similar)`, evidence, fix, skill: 'portfolio-originality' }; if (stores.apple) r.findings.push({ ...base, guideline: '4.3', store: 'apple', ref: APPLE_GUIDELINES }); if (stores.play) r.findings.push({ ...base, guideline: 'Play: Repetitive content', store: 'play', ref: 'https://support.google.com/googleplay/android-developer/answer/9898783' }); } applyIgnore(r.findings.filter(f => f.rule === 'originality'), ignore); // runScan skipped the originality entries (it cannot produce that rule); now they can be checked r.notes.push(...unusedIgnores(r.findings, ignore, []).filter(n => n.includes('(rule originality'))); r.findings = sortScan(r.findings); r.counts = { blocker: 0, high: 0, medium: 0, low: 0, accepted: 0 }; for (const f of r.findings) { if (f.accepted) r.counts.accepted++; else r.counts[f.severity]++; } r.originality = { compared: o.pairs.length, flagged: o.flagged, threshold: o.threshold, pairs: o.pairs.map(p => ({ app: p.other.name, folder: rel(p.other.dir) || '.', overall: Math.round(p.overall * 1000) / 1000, flagged: p.flagged })) }; }