162 lines
14 KiB
TypeScript
162 lines
14 KiB
TypeScript
// `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 { 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 { 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<Severity, number> & { 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<ScanRun> {
|
|
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 });
|
|
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<Severity, string> = { blocker: 'BLOCKER', high: 'HIGH ', medium: 'MEDIUM ', low: 'low ' };
|
|
const STACK: Record<Stack, string> = { '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 })) };
|
|
}
|