From a656240bfbc36712bf1363d965f1584c93fada6b Mon Sep 17 00:00:00 2001 From: CRT Date: Fri, 23 Oct 2026 09:22:22 +0800 Subject: [PATCH] Checks: findings with a skill get Open guide (context menu and a link on the card) to the shipped skills//SKILL.md; known-skill list kept equal to skills/ by a test --- apps/desktop/src-tauri/src/policy.rs | 2 ++ apps/desktop/src/i18n/en.ts | 2 ++ apps/desktop/src/i18n/tr.ts | 2 ++ apps/desktop/src/screens/tabs/Checks.tsx | 5 ++++- apps/desktop/src/styles/checks.css | 4 ++++ cli/lib/skill-guide.ts | 12 +++++++++++ tests/skill-guide.test.ts | 27 ++++++++++++++++++++++++ 7 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 cli/lib/skill-guide.ts create mode 100644 tests/skill-guide.test.ts diff --git a/apps/desktop/src-tauri/src/policy.rs b/apps/desktop/src-tauri/src/policy.rs index a03dc88..0aab7ed 100644 --- a/apps/desktop/src-tauri/src/policy.rs +++ b/apps/desktop/src-tauri/src/policy.rs @@ -190,6 +190,8 @@ pub const EXTERNAL_HOSTS: &[&str] = &[ "appstoreconnect.apple.com", "developer.apple.com", "storebound.dev", + // the guides (skills//SKILL.md) of check findings, in storebound's public repo + "git.crt.fyi", // Dependencies screen link-outs "developer.android.com", "ffmpeg.org", diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6beb640..2d8329e 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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/. 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", diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 587c795..cce0065 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -2047,6 +2047,8 @@ export const tr: Record = { '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/ 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", diff --git a/apps/desktop/src/screens/tabs/Checks.tsx b/apps/desktop/src/screens/tabs/Checks.tsx index c962290..233677a 100644 --- a/apps/desktop/src/screens/tabs/Checks.tsx +++ b/apps/desktop/src/screens/tabs/Checks.tsx @@ -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 ( {f.guideline}{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 && }{!f.accepted && fixable(f.action) && }}> {ev.slice(0, 2).map((e, j) => {e.file}:{e.line}{e.text ? ` ${e.text}` : ''})} {f.accepted ? {t('checks.accepted', { reason: f.accepted })} - : {f.fix} {f.skill}} + : {f.fix} {guide ? : f.skill && {f.skill}}} {!f.accepted && f.aiFix && !fixable(f.action) &&
{ s.refresh(); }} stillThere={findings.some(x => same(x, f))} onActive={on => setAiActive(on ? f : null)} />
}
diff --git a/apps/desktop/src/styles/checks.css b/apps/desktop/src/styles/checks.css index c396c17..fbbad0b 100644 --- a/apps/desktop/src/styles/checks.css +++ b/apps/desktop/src/styles/checks.css @@ -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; } diff --git a/cli/lib/skill-guide.ts b/cli/lib/skill-guide.ts new file mode 100644 index 0000000..dcb7813 --- /dev/null +++ b/cli/lib/skill-guide.ts @@ -0,0 +1,12 @@ +// The guide of a finding's skill: skills//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; +} diff --git a/tests/skill-guide.test.ts b/tests/skill-guide.test.ts new file mode 100644 index 0000000..d205b7d --- /dev/null +++ b/tests/skill-guide.test.ts @@ -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(); +});