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:
parent
4a74989a95
commit
a656240bfb
7 changed files with 53 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
12
cli/lib/skill-guide.ts
Normal 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
27
tests/skill-guide.test.ts
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue