Checks: findings with a skill get Open guide (context menu and a link on the card) to the shipped skills/<name>/SKILL.md; known-skill list kept equal to skills/ by a test

This commit is contained in:
CRTkafa 2026-10-23 09:22:22 +08:00
parent 4a74989a95
commit a656240bfb
7 changed files with 53 additions and 1 deletions

View file

@ -190,6 +190,8 @@ pub const EXTERNAL_HOSTS: &[&str] = &[
"appstoreconnect.apple.com",
"developer.apple.com",
"storebound.dev",
// the guides (skills/<name>/SKILL.md) of check findings, in storebound's public repo
"git.crt.fyi",
// Dependencies screen link-outs
"developer.android.com",
"ffmpeg.org",

View file

@ -2045,6 +2045,8 @@ export const en = {
'acct.noDevId': 'no Play developer id',
'acct.devIdLabel': 'Play developer account id',
'acct.devIdHint': "The digits in play.google.com/console/u/0/developers/<id>. With it, Play Console links open each app's own page; the CLI uses it too.",
'checks.openGuide': 'Open guide',
'checks.openGuideHint': 'Open the {skill} guide (SKILL.md)',
'cm.more': "More actions",
'cm.showIgnored': "Show ignored apps",
'cm.copyVersion': "Copy version",

View file

@ -2047,6 +2047,8 @@ export const tr: Record<keyof typeof en, string> = {
'acct.noDevId': 'Play geliştirici kimliği yok',
'acct.devIdLabel': 'Play geliştirici hesabı kimliği',
'acct.devIdHint': 'play.google.com/console/u/0/developers/<id> içindeki rakamlar. Girilince Play Console bağlantıları her uygulamanın kendi sayfasını açar; CLI de kullanır.',
'checks.openGuide': 'Kılavuzu aç',
'checks.openGuideHint': '{skill} kılavuzunu aç (SKILL.md)',
'cm.more': "Diğer işlemler",
'cm.showIgnored': "Yok sayılanları göster",
'cm.copyVersion': "Sürümü kopyala",

View file

@ -18,6 +18,7 @@ import { OpenLink } from '../../ui/OpenLink';
import { Bar, ErrorNotice, ReadControl, Skeleton } from '../../ui/kit';
import { EmptyState, Section, StatusPill } from '../../ui/ds';
import '../../styles/checks.css';
import { skillGuideUrl } from '../../../../../cli/lib/skill-guide';
import { exportName, validateMd } from '../../lib/exports';
import { useExports } from '../../ui/Exports';
@ -90,11 +91,13 @@ function ScanCard({ project }: { project: Project }) {
const card = ({ f, k }: { f: ScanFinding; k: string }) => {
const [tone, icon] = f.accepted ? ['ok', 'check'] as const : SEV[f.severity] ?? SEV.low;
const ev = Array.isArray(f.evidence) ? f.evidence : [];
const guide = skillGuideUrl(f.skill);
return (
<CheckCard key={k} tone={tone} icon={icon} title={<><span className="ck-gl">{f.guideline}</span>{f.title}</>}
menu={() => [
!f.accepted && fixable(f.action) && fixItem(w, f.action, f.title),
!f.accepted && f.url && /^https:\/\//.test(f.url) && { label: t('link.open'), icon: 'external', onSelect: () => openUrl(d, f.url!) },
guide && { label: t('checks.openGuide'), icon: 'sparkle', shortcut: f.skill, onSelect: () => openUrl(d, guide) },
ev[0] && { label: t('cm.revealEvidence'), icon: 'folder', shortcut: `${ev[0].file.split('/').pop()}:${ev[0].line}`, onSelect: () => { api.reveal(project.id, ev[0]!.file).catch(() => {}); } },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
@ -108,7 +111,7 @@ function ScanCard({ project }: { project: Project }) {
actions={<>{!f.accepted && <OpenLink url={f.url} />}{!f.accepted && fixable(f.action) && <FixButton action={f.action} what={f.title} w={w} />}</>}>
{ev.slice(0, 2).map((e, j) => <span className="ck-sub selectable" key={j}><code>{e.file}:{e.line}</code>{e.text ? ` ${e.text}` : ''}</span>)}
{f.accepted ? <span className="ck-sub">{t('checks.accepted', { reason: f.accepted })}</span>
: <span className="ck-fix"><Icon name="wand" /><span className="selectable">{f.fix} <code>{f.skill}</code></span></span>}
: <span className="ck-fix"><Icon name="wand" /><span className="selectable">{f.fix} {guide ? <button type="button" className="ck-guide" onClick={() => openUrl(d, guide)} title={t('checks.openGuideHint', { skill: f.skill })}><code>{f.skill}</code><Icon name="external" /></button> : f.skill && <code>{f.skill}</code>}</span></span>}
{!f.accepted && f.aiFix && !fixable(f.action) && <div className="ck-ai"><AiFix projectId={project.id} f={f} claude={claude} busy={aiBusy} setBusy={setAiBusy} rescan={() => { s.refresh(); }}
stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} /></div>}
</CheckCard>

View file

@ -74,3 +74,7 @@
.scr-checks .ck-report { display: flex; align-items: center; gap: 16px; padding: 14px 18px; border-radius: var(--ds-r-lg); background: var(--ds-surface); border: 1px solid var(--ds-stroke); }
.scr-checks .ck-report-t { display: grid; gap: 2px; flex: 1; min-width: 0; font-size: var(--ds-t-meta); color: var(--muted); }
.scr-checks .ck-report-t b { color: var(--ink); font-size: var(--ds-t-body); }
.scr-checks .ck-guide { all: unset; display: inline-flex; align-items: center; gap: 4px; cursor: pointer; border-radius: var(--ds-r-xs); }
.scr-checks .ck-guide:hover code { text-decoration: underline; }
.scr-checks .ck-guide:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.scr-checks .ck-guide svg { width: 12px; height: 12px; margin: 0; }

12
cli/lib/skill-guide.ts Normal file
View file

@ -0,0 +1,12 @@
// The guide of a finding's skill: skills/<name>/SKILL.md ships with the toolchain (npm package, plugin) and is published
// in the public repo, which is where "Open guide" points (storebound.dev does not host the skills). Only known skill
// names get a link (tests keep the list equal to the skills/ folder), so a finding never opens a made-up page.
export const SKILLS_BASE = 'https://git.crt.fyi/CRT/storebound/src/branch/main/skills';
export const KNOWN_SKILLS: readonly string[] = ['account-deletion', 'adhoc-distribution', 'ads-levelplay-checks', 'ads-mediation-checks', 'app-icons', 'apple-ads', 'app-previews', 'appstore-first-release', 'app-store-promotion', 'aso-basics', 'build-and-sign', 'console-autofill', 'export-compliance', 'game-services', 'listing-localization', 'migrate-from-fastlane', 'play-first-release', 'portfolio-originality', 'ppp-pricing', 'privacy-manifest', 'privacy-policy', 'release-flows', 'release-management', 'revenuecat-sync', 'review-access', 'review-rejections', 'review-replies', 'store-analytics', 'store-api', 'store-declarations', 'store-health', 'store-iap-setup', 'store-pitfalls', 'store-preflight', 'store-screenshots', 'store-subscriptions', 'testflight', 'web-presence', 'whats-new'];
/** The guide URL of a skill name (a finding's `skill`), or null when it is not a skill storebound ships. */
export function skillGuideUrl(skill: string | null | undefined): string | null {
const s = skill?.trim().replace(/^skills\//, '').replace(/\/SKILL\.md$/i, '');
return s && KNOWN_SKILLS.includes(s) ? `${SKILLS_BASE}/${s}/SKILL.md` : null;
}

27
tests/skill-guide.test.ts Normal file
View file

@ -0,0 +1,27 @@
// "Open guide" on a finding: skill name -> the shipped SKILL.md; the known list matches the skills/ folder.
import { expect, test } from 'bun:test';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { KNOWN_SKILLS, SKILLS_BASE, skillGuideUrl } from '../cli/lib/skill-guide';
const dir = join(import.meta.dir, '..', 'skills');
test('KNOWN_SKILLS is exactly the skills/ folder', () => {
const real = readdirSync(dir).filter(n => existsSync(join(dir, n, 'SKILL.md'))).sort();
expect([...KNOWN_SKILLS].sort()).toEqual(real);
});
test('a skill name, a skills/ path or a SKILL.md path resolve to the guide', () => {
expect(skillGuideUrl('store-api')).toBe(`${SKILLS_BASE}/store-api/SKILL.md`);
expect(skillGuideUrl('skills/store-api')).toBe(`${SKILLS_BASE}/store-api/SKILL.md`);
expect(skillGuideUrl(' skills/game-services/SKILL.md ')).toBe(`${SKILLS_BASE}/game-services/SKILL.md`);
});
test('unknown or empty: no link', () => {
for (const s of ['', null, undefined, 'not-a-skill', '../etc', 'store-api/../x']) expect(skillGuideUrl(s)).toBeNull();
});
test('every skill a scan rule names is a known skill', async () => {
const { readFileSync } = await import('node:fs');
const src = readdirSync(join(import.meta.dir, '..', 'cli', 'lib', 'scan')).filter(f => f.endsWith('.ts')).map(f => readFileSync(join(import.meta.dir, '..', 'cli', 'lib', 'scan', f), 'utf8')).join('\n');
const named = [...src.matchAll(/skill: '([a-z0-9-]+)'/g)].map(m => m[1]!);
expect(named.length).toBeGreaterThan(0);
for (const s of new Set(named)) expect(skillGuideUrl(s)).not.toBeNull();
});