desktop: a Fix button on scan and validate findings storebound can fix itself. Findings carry action { method, args } (cli/lib/fix-actions.ts); privacy-labels runs scan.write (scan --write-only, the dialog shows the config diff), Play listing gaps play.listing, a missing App Store localization or copyright apple.listing (editable version only), the App Review contact apple.review_details; each a dry run, the native dialog, then the confirmed run. --write-only is forbidden for MCP tools; tests pin the mapping, the round trip on a fixture and that no fix reaches a submit, release or publish

This commit is contained in:
CRTkafa 2026-10-15 06:14:49 +08:00
parent bd83d036ba
commit 992f5bcc0e
18 changed files with 257 additions and 37 deletions

View file

@ -339,6 +339,7 @@ const CATALOG: Record<string, CatalogDiff> = {
const VALIDATE: Record<string, ValidateResult> = {
'p-prism': {
blockers: 1, findings: [
{ store: 'play', level: 'blocker', code: 'listing', msg: 'ja-JP: missing shortDescription', fix: 'storebound play listing', action: { method: 'play.listing', args: {} } },
{ store: 'play', level: 'blocker', code: 'product-missing', msg: 'starter_pack is in the config but not on Google Play', fix: 'storebound play products --only starter_pack (dry run first)' },
{ store: 'apple', level: 'warn', code: 'screenshots-ipad', msg: 'ar-SA has no 13" iPad screenshots', fix: 'storebound shots render --lang ar, then storebound apple screenshots --only ar-SA' },
{ store: 'apple', level: 'warn', code: 'whats-new', msg: '2.4.0 has no "What\'s New" for ja', fix: 'storebound whats-new --since v2.3.0' },
@ -363,6 +364,9 @@ const SCAN_PRISM: ScanResult['findings'] = [
evidence: [{ file: 'storebound.config.ts', line: 41, text: 'no_ads (non_consumable)' }], fix: 'Add a visible "Restore Purchases" button on the store screen or in Settings.', skill: 'store-iap-setup', ref: 'https://developer.apple.com/app-store/review/guidelines/' },
{ rule: 'demo-account', guideline: '2.1(a)', store: 'apple', severity: 'blocker', title: 'Sign-in is required but there is no demo account for App Review',
evidence: [{ file: 'storebound.config.ts', line: 17, text: 'reviewAccess: { signInRequired: true }' }], fix: 'Add the App Review demo account in .storebound/ (review-access skill).', skill: 'review-access', ref: 'https://developer.apple.com/app-store/review/guidelines/', accepted: 'leaderboards only; the game plays without signing in' },
// a deterministic fix (scan.write: the detected answers into the config)
{ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', store: 'play', severity: 'medium', title: 'Data safety likely misses PSL_DEVICE_ID: an SDK collects it, dataSafety does not declare it',
evidence: [{ file: 'Packages/manifest.json', line: 4, text: 'com.applovin.mediation.ads 8.1.0' }], fix: 'Confirm with the SDK\'s Data safety guidance, add it with `storebound scan --write` (or an edit of the config), then `storebound play data-safety`.', skill: 'store-declarations', ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', action: { method: 'scan.write', args: {} } },
];
const SCAN: Record<string, ScanResult> = {
'p-prism': { stacks: ['unity'], sdks: [{ id: 'applovin', name: 'AppLovin MAX', category: 'ads', tracking: true }, { id: 'unity-ads', name: 'Unity Ads', category: 'ads', tracking: true }, { id: 'gameanalytics', name: 'GameAnalytics', category: 'analytics', tracking: false }],
@ -593,6 +597,9 @@ async function answer(cmd: string, a: Record<string, unknown>): Promise<unknown>
if (method === 'reviews.approve' && d) d.approved = !args.undo;
if (method === 'reviews.reply' && d) { d.reply = String(args.text); d.approved = false; d.issues = []; }
if (method === 'owner.reply_send' && d) d.sent = iso(0);
// a Fix button's write: the finding it fixed is gone on the next read
if (method === 'scan.write') { const i = SCAN_PRISM.findIndex(f => f.rule === 'privacy-labels'); if (i >= 0) SCAN_PRISM.splice(i, 1); SCAN['p-prism']!.counts = scanCounts(SCAN_PRISM); }
if (method === 'play.listing') { const v = VALIDATE[a.projectId as string]; if (v) v.findings = v.findings.filter(f => f.code !== 'listing'); }
const res: ToolResult = { method, ok: true, exitCode: 0, dryRun: false, command: `storebound ${method.replace('.', ' ')}`, output: `[${method}] done (fixture)` };
return { confirmed: true, stage: 'done', dryRun: { ...res, dryRun: true }, result: res };
}

View file

@ -437,6 +437,13 @@ export const en = {
'checks.scanAsk': '{count} declaration answers only you can give',
'checks.scanAsk_one': '1 declaration answer only you can give',
'checks.scanWrite': 'Detected Data safety / App Privacy answers differ from the config: storebound scan --write adds them',
'checks.fix': 'Fix',
'checks.fixing': 'Fix: {what}',
'checks.fixed': 'Fixed. Checking again',
'checks.fixHint.scan.write': 'Writes the detected Data safety / App Privacy answers into the config. You see the diff before anything is written.',
'checks.fixHint.play.listing': 'Pushes the listing file to Google Play (text only, nothing is published). You see the dry run first.',
'checks.fixHint.apple.listing': 'Pushes the listing file and copyright to the App Store version (text only, nothing is submitted). You see the dry run first.',
'checks.fixHint.apple.review_details': 'Sends the App Review contact and notes from the config (nothing is submitted). You see the dry run first.',
'checks.accepted': 'Accepted: {reason}',
'checks.sev.blocker': 'Blocker',
'checks.sev.high': 'High',

View file

@ -439,6 +439,13 @@ export const tr: Record<keyof typeof en, string> = {
'checks.scanAsk': 'Yalnızca senin cevaplayabileceğin {count} beyan sorusu',
'checks.scanAsk_one': 'Yalnızca senin cevaplayabileceğin 1 beyan sorusu',
'checks.scanWrite': 'Bulunan Data safety / App Privacy cevapları ayardan farklı: storebound scan --write ekler',
'checks.fix': 'Düzelt',
'checks.fixing': 'Düzelt: {what}',
'checks.fixed': 'Düzeltildi. Yeniden kontrol ediliyor',
'checks.fixHint.scan.write': 'Bulunan Data safety / App Privacy cevaplarını ayara yazar. Bir şey yazılmadan önce farkı görürsün.',
'checks.fixHint.play.listing': "Mağaza metni dosyasını Google Play'e gönderir (yalnız metin, yayınlanmaz). Önce deneme çalıştırmasını görürsün.",
'checks.fixHint.apple.listing': 'Mağaza metnini ve telif hakkını App Store sürümüne gönderir (yalnız metin, incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'checks.fixHint.apple.review_details': 'App Review iletişim bilgisini ve notları ayardan gönderir (incelemeye gönderilmez). Önce deneme çalıştırmasını görürsün.',
'checks.accepted': 'Kabul edildi: {reason}',
'checks.sev.blocker': 'Engel',
'checks.sev.high': 'Yüksek',

View file

@ -39,13 +39,15 @@ export interface TestFlightStatus { groups: TfGroup[]; builds: TfBuild[]; missin
export interface CatalogRow { id: string; store: 'apple' | 'play'; level: 'error' | 'warn' | 'ok'; code: string; msg: string }
export interface ParityRow { id: string; region: string; currency: string; apple: number; play: number; diffPct: number }
export interface CatalogDiff { errors: number; rows: CatalogRow[]; parity: ParityRow[] }
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string }
/** A finding's machine-readable fix (cli/lib/fix-actions.ts): the write the Fix button runs (dry run, dialog, apply). */
export interface FixAction { method: string; args: Record<string, string> }
export interface Finding { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; action?: FixAction }
export interface ValidateResult { blockers: number; findings: Finding[] }
/** `storebound scan --json` (offline rejection-risk scan of the project files), the parts the Checks tab shows. */
export type ScanSeverity = 'blocker' | 'high' | 'medium' | 'low';
export interface ScanFinding {
rule: string; guideline: string; store: 'apple' | 'play'; severity: ScanSeverity; title: string;
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string;
evidence: { file: string; line: number; text?: string }[]; fix: string; skill: string; ref: string; accepted?: string; action?: FixAction;
}
export interface ScanResult {
stacks: string[];

View file

@ -3,8 +3,9 @@
import type { Project } from '../../api';
import { t } from '../../i18n';
import { ago } from '../../lib/format';
import type { Finding, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import { useData, useRead } from '../../store';
import type { Finding, FixAction, ScanResult, ScanSeverity, ValidateResult } from '../../lib/model';
import { useData, useRead, useWrite } from '../../store';
import { WriteButton } from '../../ui/WriteButton';
import { Icon } from '../../ui/icons';
import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
import { exportName, validateMd } from '../../lib/exports';
@ -12,11 +13,25 @@ import { useExports } from '../../ui/Exports';
const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'alert' | 'check']> = { blocker: ['bad', 'alertCircle'], warn: ['warn', 'alert'], ok: ['ok', 'check'] };
/** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store
* text pushes, each a dry run, the native dialog showing it, then the real run. Anything else gets no button. */
export const FIX_METHODS = new Set(['scan.write', 'play.listing', 'apple.listing', 'apple.review_details']);
export const fixable = (a: FixAction | undefined): a is FixAction => !!a && FIX_METHODS.has(a.method) && !!a.args && typeof a.args === 'object';
/** The Fix button of one finding: its action as a confirm-gated write. */
function FixButton({ action, what, w }: { action: FixAction; what: string; w: ReturnType<typeof useWrite> }) {
return (
<WriteButton run={w} icon="wand" hint={t(`checks.fixHint.${action.method}` as Parameters<typeof t>[0])}
w={{ method: action.method, args: action.args, title: t('checks.fixing', { what }), done: t('checks.fixed') }}>{t('checks.fix')}</WriteButton>
);
}
const SEV: Record<ScanSeverity, ['bad' | 'warn' | 'info', 'alertCircle' | 'alert' | 'info']> = { blocker: ['bad', 'alertCircle'], high: ['bad', 'alert'], medium: ['warn', 'alert'], low: ['info', 'info'] };
/** The `storebound scan` card: rejection risks with guideline, evidence file:line, fix and skill. */
function ScanCard({ project }: { project: Project }) {
const s = useRead<ScanResult>(project.id, 'scan', { network: false, title: t('checks.scan') });
const w = useWrite(project.id);
const findings = Array.isArray(s.data?.findings) ? s.data!.findings : [];
const sdks = Array.isArray(s.data?.sdks) ? s.data!.sdks : [];
const asks = (Array.isArray(s.data?.declarations?.items) ? s.data!.declarations.items : []).filter(x => x.status === 'ask').length;
@ -39,6 +54,7 @@ function ScanCard({ project }: { project: Project }) {
{f.accepted ? <span className="li-sub">{t('checks.accepted', { reason: f.accepted })}</span>
: <span className="fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
</div>
{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}
<Chip tone={f.accepted ? 'ok' : tone}>{t(f.accepted ? 'checks.sev.accepted' : `checks.sev.${f.severity}` as Parameters<typeof t>[0])}</Chip>
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
</div>
@ -60,6 +76,7 @@ export function ChecksTab({ project }: { project: Project }) {
const d = useData();
const e = d.statusOf(project);
const v = useRead<ValidateResult>(project.id, 'validate', { network: true, title: t('checks.validate') });
const w = useWrite(project.id);
const name = e?.summary?.name ?? project.name;
useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'), build: () => validateMd(name, v.data!, v.at) }]);
const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])];
@ -95,6 +112,7 @@ export function ChecksTab({ project }: { project: Project }) {
<span className="li-title">{f.msg}</span>
{f.fix && <span className="fix"><Icon name="wand" /><code className="selectable">{f.fix}</code></span>}
</div>
{f.level !== 'ok' && fixable(f.action) && <FixButton action={f.action} what={f.msg} w={w} />}
<Chip tone="outline">{f.store === 'apple' ? 'App Store' : 'Google Play'}</Chip>
</div>
);