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

@ -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>
);