// The shareable store readiness report (`storebound report`, the desktop's Report button and its Markdown export, the // MCP `report` tool): one data shape built from `scan`, `check` and `validate`, rendered as a self-contained HTML page // (inline CSS, no scripts, no external requests, light and dark), as Markdown or as JSON. Pure: no file system, no // network, so the desktop window imports it too. Every text goes through maskSecrets: credentials, key ids, emails, // tokens and the review demo account never reach the page (the command also passes the literal values it knows). export type ReportStore = 'apple' | 'play' | 'both'; export type ReportLevel = 'blocker' | 'warn' | 'ok' | 'info'; export type ReportSource = 'scan' | 'check' | 'validate'; export interface ReportFinding { store: ReportStore; level: ReportLevel; source: ReportSource; /** Rule / check id (scan rule, validate code, "check"). */ rule: string; guideline?: string; title: string; /** file:line evidence. */ evidence: string[]; fix?: string; /** Absolute console deep link. */ url?: string; } export interface ReportQuestion { store: ReportStore; text: string; source: string; /** Console page where it is answered. */ url?: string } export interface SourceState { ran: boolean; note?: string } export interface StoreSummary { blockers: number; warnings: number; ok: number } export interface ReportData { app: { name: string; version?: string; build?: string }; /** The stores the config has (the summary shows these and "both"). */ stores: ('apple' | 'play')[]; /** Per store: was the store itself read (validate), and if not, why. */ read: Record<'apple' | 'play', { checked: boolean; why?: string }>; generatedAt: string; storebound: string; sources: Record; summary: Record; findings: ReportFinding[]; questions: ReportQuestion[]; } // ---- inputs (the --json shapes of the three commands) ---- export interface ScanInput { findings?: { rule: string; guideline: string; store: 'apple' | 'play'; severity: 'blocker' | 'high' | 'medium' | 'low'; title: string; evidence?: { file: string; line: number }[]; url?: string; fix: string; skill?: string; accepted?: string }[]; declarations?: { items?: { area: string; what: string; value: string; status: 'detected' | 'ask'; note?: string }[] }; } export interface CheckInput { ok?: boolean; items?: { what: string; detail: string }[]; warnings?: string[]; hints?: string[]; problems?: string[] } export interface ValidateInput { blockers?: number; findings?: { store: 'apple' | 'play'; level: 'blocker' | 'warn' | 'ok'; code: string; msg: string; fix?: string; url?: string }[] } export interface BuildInput { app: ReportData['app']; stores?: ('apple' | 'play')[]; /** Per store: true when validate read it, else the reason it did not. */ storeRead?: Partial>; generatedAt: string; storebound: string; /** Console deep link for a finding that carries none (cli/lib/console-links.ts, chosen by the command). */ linkFor?: (f: ReportFinding) => string | undefined; scan?: ScanInput | null; check?: CheckInput | null; validate?: ValidateInput | null; /** Why a source did not run (e.g. no credentials). */ notes?: Partial>; /** Questions from elsewhere (console plans' ask-owner answers). */ questions?: ReportQuestion[]; /** Literal secret values to mask wherever they appear. */ secrets?: string[]; } const ORDER: ReportLevel[] = ['blocker', 'warn', 'info', 'ok']; const STORES: ReportStore[] = ['apple', 'play', 'both']; export const STORE_LABEL: Record = { apple: 'App Store', play: 'Google Play', both: 'Both stores' }; const LEVEL_LABEL: Record = { blocker: 'Blocker', warn: 'Warning', info: 'Note', ok: 'OK' }; /** A message that asks the owner for an answer only they know (compliance). */ const ASK_RE = /ask the owner|still to answer|still holds template answers|answers? (?:is|are) not in the config/i; /** The store a free-text check line is about, from its prefix. */ function storeOf(msg: string): ReportStore { if (/^(play|google play|android)\b/i.test(msg)) return 'play'; if (/^(apple|app store|appstore|ios|testflight)\b/i.test(msg)) return 'apple'; return 'both'; } /** Masks anything that looks like a secret: PEM blocks, private key JSON, JWTs, emails, UUIDs (issuer ids), App Store * Connect key ids, long tokens, plus every literal value in `known`. */ export function maskSecrets(text: string, known: string[] = []): string { let s = text; for (const k of [...new Set(known)].filter(k => k && k.length >= 4).sort((a, b) => b.length - a.length)) s = s.split(k).join('[hidden]'); return s .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----|$)/g, '[private key hidden]') .replace(/"private_key(_id)?"\s*:\s*"[^"]*"/g, '"private_key$1": "[hidden]"') .replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, '[token hidden]') .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, m => (/@example\.(com|org|net)$/i.test(m) ? m : '[email hidden]')) .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '[id hidden]') .replace(/\b(?=[A-Z0-9]*\d)(?=[A-Z0-9]*[A-Z])[A-Z0-9]{10}\b/g, '[key id hidden]') .replace(/\b(?:sk|pk|rk|ghp|gho|xox[abp]|AIza)[_-]?[A-Za-z0-9_-]{16,}\b/g, '[token hidden]') .replace(/\b[A-Za-z0-9+/_-]{40,}={0,2}/g, m => (/\d/.test(m) && /[A-Za-z]/.test(m) && !(m.includes('/') && !/[+=]/.test(m)) ? '[token hidden]' : m)); } const severityLevel = (s: string): ReportLevel => (s === 'blocker' ? 'blocker' : s === 'low' ? 'info' : 'warn'); /** One report from the three command outputs: findings deduplicated, findings shown on both stores merged into * "both", sorted by severity, the owner's open questions collected. */ export function buildReport(i: BuildInput): ReportData { const raw: ReportFinding[] = []; for (const f of i.scan?.findings ?? []) { if (f.accepted) continue; raw.push({ store: f.store, level: severityLevel(f.severity), source: 'scan', rule: f.rule, guideline: f.guideline, title: f.title, evidence: (f.evidence ?? []).slice(0, 5).map(e => `${e.file}:${e.line}`), fix: f.skill ? `${f.fix} (skill: ${f.skill})` : f.fix, ...(f.url ? { url: f.url } : {}) }); } const c = i.check; for (const p of c?.problems ?? []) raw.push({ store: storeOf(p), level: 'blocker', source: 'check', rule: 'check', title: p, evidence: [] }); for (const w of c?.warnings ?? []) raw.push({ store: storeOf(w), level: 'warn', source: 'check', rule: 'check', title: w, evidence: [] }); for (const h of c?.hints ?? []) raw.push({ store: storeOf(h), level: 'info', source: 'check', rule: 'check', title: h, evidence: [] }); for (const it of c?.items ?? []) raw.push({ store: storeOf(it.what), level: 'ok', source: 'check', rule: 'check', title: `${it.what}: ${it.detail}`, evidence: [] }); for (const f of i.validate?.findings ?? []) { const ev = /\(([^()\s]+:\d+)\)\s*$/.exec(f.msg)?.[1]; raw.push({ store: f.store, level: f.level, source: 'validate', rule: f.code, title: f.msg, evidence: ev ? [ev] : [], ...(f.fix ? { fix: f.fix } : {}), ...(f.url ? { url: f.url } : {}) }); } // same text on both stores -> one "both" finding; exact duplicates (validate repeats check's template lines) dropped const key = (f: ReportFinding) => `${f.level}|${f.rule}|${f.title}`; const seen = new Map(); for (const f of raw) { const k = key(f); const had = seen.get(k) ?? [...seen.values()].find(x => x.title === f.title && x.level === f.level); if (!had) { seen.set(k, { ...f }); continue; } if (had.store !== f.store) had.store = 'both'; if (!had.url && f.url) had.url = f.url; if (!had.fix && f.fix) had.fix = f.fix; } if (i.linkFor) for (const f of seen.values()) if (!f.url && f.level !== 'ok') { const u = i.linkFor(f); if (u) f.url = u; } const findings = [...seen.values()].sort((a, b) => ORDER.indexOf(a.level) - ORDER.indexOf(b.level) || STORES.indexOf(a.store) - STORES.indexOf(b.store)); const questions: ReportQuestion[] = []; const ask = (q: ReportQuestion) => { if (!questions.some(x => x.text === q.text)) questions.push(q); }; for (const d of i.scan?.declarations?.items ?? []) if (d.status === 'ask' && d.area !== 'privacyManifest') ask({ store: d.area === 'appPrivacy' || d.area === 'att' ? 'apple' : d.area === 'dataSafety' ? 'play' : 'both', text: `${d.what}: ${d.value}${d.note ? ` (${d.note})` : ''}`, source: 'scan' }); for (const f of findings) if (f.level !== 'ok' && ASK_RE.test(f.title)) ask({ store: f.store, text: f.title, source: f.source }); for (const q of i.questions ?? []) ask(q); const summary = Object.fromEntries(STORES.map(s => [s, { blockers: 0, warnings: 0, ok: 0 }])) as Record; for (const f of findings) { const s = summary[f.store]; if (f.level === 'blocker') s.blockers++; else if (f.level === 'warn') s.warnings++; else if (f.level === 'ok') s.ok++; } const sources: Record = { scan: { ran: !!i.scan, ...(i.notes?.scan ? { note: i.notes.scan } : {}) }, check: { ran: !!i.check, ...(i.notes?.check ? { note: i.notes.check } : {}) }, validate: { ran: !!i.validate, ...(i.notes?.validate ? { note: i.notes.validate } : {}) }, }; const stores = i.stores ?? (['apple', 'play'] as const).filter(s => raw.some(f => f.store === s)); const readOf = (st: 'apple' | 'play') => { const v = i.storeRead?.[st]; return v === true ? { checked: true } : { checked: false, ...(v ? { why: v } : {}) }; }; return maskReport({ app: i.app, stores, read: { apple: readOf('apple'), play: readOf('play') }, generatedAt: i.generatedAt, storebound: i.storebound, sources, summary, findings, questions }, i.secrets ?? []); } /** The report with every string masked (maskSecrets); urls only when they are https. */ export function maskReport(r: ReportData, known: string[] = []): ReportData { const m = (s: string) => maskSecrets(s, known); const mo = (s: T): T => (s === undefined ? s : m(s) as T); const safeUrl = (u?: string) => (u && /^https:\/\/[^\s"'<>]+$/.test(u) ? u : undefined); return { ...r, app: { name: m(r.app.name), ...(r.app.version ? { version: m(r.app.version) } : {}), ...(r.app.build ? { build: m(r.app.build) } : {}) }, read: { apple: { ...r.read.apple, ...(r.read.apple.why ? { why: m(r.read.apple.why) } : {}) }, play: { ...r.read.play, ...(r.read.play.why ? { why: m(r.read.play.why) } : {}) } }, sources: Object.fromEntries(Object.entries(r.sources).map(([k, v]) => [k, { ran: v.ran, ...(v.note ? { note: m(v.note) } : {}) }])) as ReportData['sources'], findings: r.findings.map(f => { const url = safeUrl(f.url); const { url: _drop, ...rest } = f; return { ...rest, title: m(f.title), guideline: mo(f.guideline), fix: mo(f.fix), evidence: f.evidence.map(m), ...(url ? { url } : {}) }; }), questions: r.questions.map(q => { const url = safeUrl(q.url); const { url: _u, ...rest } = q; return { ...rest, text: m(q.text), ...(url ? { url } : {}) }; }), }; } // ---- renderers ---- export const escapeHtml = (s: string): string => s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); /** The summary columns: both stores always (a store the config lacks shows "Not set up"), plus "both" when anything is on both. */ const shown = (r: ReportData): ReportStore[] => ['apple', 'play', ...(r.findings.some(f => f.store === 'both') ? ['both' as const] : [])]; const appLine = (r: ReportData) => [r.app.version && `version ${r.app.version}`, r.app.build && `build ${r.app.build}`].filter(Boolean).join(' · '); const sourceLine = (name: ReportSource, s: SourceState) => `${name}: ${s.ran ? 'ran' : 'skipped'}${s.note ? ` (${s.note})` : ''}`; // storebound.dev's look (site/style.css tokens): slate ink, the indigo accent, the system font stack, 14px radius. const CSS = `:root{color-scheme:light dark;--bg:#f8fafc;--surface:#fff;--ink:#0f172a;--ink-2:#334155;--muted:#64748b;--line:#e2e8f0;--accent:#4f46e5;--accent-ink:#fff;--accent-soft:#eef2ff; --bad-bg:#fee2e2;--bad:#991b1b;--warn-bg:#fef3c7;--warn:#92400e;--note-bg:#eef2ff;--note:#3730a3;--ok-bg:#dcfce7;--ok:#166534;--radius:14px; --shadow:0 1px 2px rgba(15,23,42,.06),0 8px 24px -12px rgba(15,23,42,.18);--font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace} @media (prefers-color-scheme:dark){:root{--bg:#0b1120;--surface:#131c2f;--ink:#f1f5f9;--ink-2:#cbd5e1;--muted:#94a3b8;--line:#1e293b;--accent:#818cf8;--accent-ink:#0b1120;--accent-soft:#1e1b4b; --bad-bg:#450a0a;--bad:#fca5a5;--warn-bg:#451a03;--warn:#fcd34d;--note-bg:#1e1b4b;--note:#c7d2fe;--ok-bg:#14532d;--ok:#86efac;--shadow:0 1px 2px rgba(0,0,0,.4),0 12px 32px -16px rgba(0,0,0,.7)}} *{box-sizing:border-box}html{-webkit-text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 var(--font);font-variant-numeric:tabular-nums} main{max-width:1180px;margin:0 auto;padding:40px 16px 56px} .small,.muted,dl,.tag,.pill,footer,summary{font-size:13px}.muted{color:var(--muted)} header.top{display:flex;gap:16px;align-items:center;margin-bottom:28px} header.top img{width:64px;height:64px;border-radius:15px;box-shadow:var(--shadow);flex:none} h1{font-size:28px;line-height:1.2;margin:0 0 4px;letter-spacing:-.01em}h2{font-size:20px;line-height:1.3;margin:0 0 4px}h3{font-size:15px;margin:0} .verdicts{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:16px;margin:0 0 40px} .verdict{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:20px;box-shadow:var(--shadow)} .verdict .big{font-size:26px;font-weight:700;line-height:1.25;margin:6px 0 8px} .verdict.bad .big{color:var(--bad)}.verdict.ok .big{color:var(--ok)}.verdict.none .big{color:var(--muted)} .counts{display:flex;gap:16px;flex-wrap:wrap;color:var(--ink-2);font-size:13px}.counts b{font-weight:600} .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:32px;align-items:start} section.store{min-width:0;margin-bottom:40px}section.store>.head{margin-bottom:16px} .card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;margin:0 0 12px;overflow-wrap:anywhere} .card .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px} .pill{display:inline-block;font-weight:600;padding:2px 10px;border-radius:999px;line-height:1.5} .pill.blocker{background:var(--bad-bg);color:var(--bad)}.pill.warn{background:var(--warn-bg);color:var(--warn)}.pill.info{background:var(--note-bg);color:var(--note)}.pill.ok{background:var(--ok-bg);color:var(--ok)} .tag{color:var(--muted);border:1px solid var(--line);border-radius:999px;padding:1px 9px} .title{font-weight:600;margin:0 0 6px}.fix{color:var(--ink-2);margin:6px 0 0} .ev{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0 0}code{font:13px/1.5 var(--mono);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:1px 6px} .btn{display:inline-block;margin-top:12px;background:var(--accent);color:var(--accent-ink);text-decoration:none;font-weight:600;font-size:13px;padding:7px 14px;border-radius:10px} .btn:hover{filter:brightness(1.08)}.btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px} .clean{background:var(--surface);border:1px dashed var(--line);border-radius:var(--radius);padding:20px;color:var(--muted)} details{margin:8px 0 12px}summary{cursor:pointer;color:var(--ink-2);font-weight:600;padding:6px 0}details .card{margin-top:8px} .q{list-style:none;padding:0;margin:12px 0 0;display:grid;gap:10px} .q li{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius);padding:12px 16px} .q label{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.q input{width:18px;height:18px;margin:3px 0 0;accent-color:var(--accent);flex:none} .q a{color:var(--accent);font-size:13px;font-weight:600} .questions{margin-top:8px}.qcols{margin-top:20px}.qcol h3{margin-bottom:4px}footer{margin-top:48px;color:var(--muted);border-top:1px solid var(--line);padding-top:16px} @media (max-width:560px){main{padding-top:24px}h1{font-size:24px}header.top img{width:48px;height:48px;border-radius:11px}} @media print{:root{color-scheme:light}body{background:#fff}.btn,.q a{display:none}.card,.verdict,.q li{box-shadow:none;break-inside:avoid} .cols{display:block}section.store+section.store,.questions{break-before:page}details>*{display:block}main{padding:0}}`; const PILL: Record = { blocker: 'Blocker', warn: 'Warning', info: 'Note', ok: 'Passed' }; const consoleName = (url: string) => (/appstoreconnect\.apple\.com|developer\.apple\.com/.test(url) ? 'App Store Connect' : /play\.google\.com/.test(url) ? 'Play Console' : 'the console'); function findingHtml(f: ReportFinding): string { const e = escapeHtml; const tags = [f.guideline && `${e(f.guideline)}`, `${e(f.rule)}`].filter(Boolean).join(''); return `
${PILL[f.level]}${tags}
` + `

${e(f.title)}

` + (f.evidence.length ? `
${f.evidence.map(x => `${e(x)}`).join('')}
` : '') + (f.fix ? `

${e(f.fix)}

` : '') + (f.url ? `Open in ${consoleName(f.url)}` : '') + '
'; } /** The verdict of one store: blockers first, then whether the store itself was read. */ function verdictOf(r: ReportData, s: ReportStore): { cls: string; big: string; sub: string } { const x = r.summary[s]; const read = s === 'both' ? undefined : r.read[s]; const notRead = read && !read.checked ? `Store not read${read.why ? `: ${read.why}` : ''}` : ''; if (x.blockers) return { cls: 'bad', big: `${x.blockers} blocker${x.blockers === 1 ? '' : 's'}`, sub: notRead }; if (read && !read.checked && /section in the config/.test(read.why ?? '')) return { cls: 'none', big: 'Not set up', sub: `Not checked: ${read.why} (storebound init adds it)` }; if (notRead) return { cls: 'none', big: `Not checked (${read?.why && /credential|key|service account/i.test(read.why) ? 'no credentials' : read?.why ?? 'not read'})`, sub: `Store not read: ${read?.why ?? 'unknown'}. Offline checks found no blockers.` }; return { cls: 'ok', big: s === 'both' ? 'No shared blockers' : 'Ready to submit', sub: '' }; } export interface HtmlOptions { /** The app icon as a data: URL (PNG / JPEG / WebP only). */ icon?: string | null } const ICON_RE = /^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/; /** The self-contained HTML page: inline CSS, no script, no external request. */ export function renderHtml(r: ReportData, o: HtmlOptions = {}): string { const e = escapeHtml; const cols = shown(r); const verdict = (s: ReportStore) => { const v = verdictOf(r, s), x = r.summary[s]; return `

${STORE_LABEL[s]}

${e(v.big)}
` + `
${x.blockers} blockers${x.warnings} warnings${x.ok} passed
` + (v.sub ? `
${e(v.sub)}
` : '') + '
'; }; const section = (s: ReportStore) => { const open = r.findings.filter(f => f.store === s && (f.level === 'blocker' || f.level === 'warn')); const notes = r.findings.filter(f => f.store === s && f.level === 'info'); const ok = r.findings.filter(f => f.store === s && f.level === 'ok'); if (s === 'both' && !open.length && !notes.length && !ok.length) return ''; const head = `

${STORE_LABEL[s]}

${open.length ? `${open.length} to fix, most severe first` : 'Nothing to fix'}
`; const fold = (label: string, list: ReportFinding[]) => (list.length ? `
${list.length} ${label}${list.map(findingHtml).join('')}
` : ''); const empty = s !== 'both' && !r.stores.includes(s) ? `Not set up: ${r.read[s].why ?? 'not in the config'}.` : 'No blockers or warnings.'; return `
${head}${open.map(findingHtml).join('') || `
${e(empty)}
`}${fold(notes.length === 1 ? 'note' : 'notes', notes)}${fold('passed', ok)}
`; }; const both = cols.includes('both') ? section('both') : ''; const stores = cols.filter(s => s !== 'both'); const meta = [r.app.version && `Version ${r.app.version}`, r.app.build && `build ${r.app.build}`, `checked ${r.generatedAt.slice(0, 10)}`].filter(Boolean).join(' · '); const icon = o.icon && ICON_RE.test(o.icon) ? `` : ''; const qList = (st: ReportStore) => { const list = r.questions.filter(x => x.store === st); if (st === 'both' && !list.length) return ''; const none = st !== 'both' && !r.stores.includes(st) ? 'Not set up in this config.' : 'Nothing open: every answer is in the config.'; return `

${STORE_LABEL[st]}

${list.length ? `` : `
${none}
`}
`; }; const q = `
${(['apple', 'play', 'both'] as const).map(qList).join('')}
`; return ` ${e(r.app.name)}: store readiness
${icon}

${e(r.app.name)}

${e(meta)}
${cols.map(verdict).join('')}
${both}
${stores.map(section).join('')}

Questions only you can answer

Compliance facts no code or config can prove. Answer them once; storebound writes them into the config.
${q}
`; } const mdCell = (s: string) => s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' '); /** The same report as Markdown (desktop export, `--md`). */ export function renderMarkdown(r: ReportData): string { const out: string[] = [`# ${r.app.name}: store readiness`, '']; const meta = appLine(r); if (meta) out.push(meta, ''); out.push('| store | verdict | blockers | warnings | passed |', '|---|---|---|---|---|', ...shown(r).map(s => `| ${STORE_LABEL[s]} | ${mdCell(verdictOf(r, s).big)} | ${r.summary[s].blockers} | ${r.summary[s].warnings} | ${r.summary[s].ok} |`), ''); out.push((Object.keys(r.sources) as ReportSource[]).map(k => sourceLine(k, r.sources[k])).join(' · '), ''); for (const s of STORES) { const list = r.findings.filter(f => f.store === s && f.level !== 'ok'); if (!list.length) continue; out.push(`## ${STORE_LABEL[s]}`, '', '| level | rule | guideline | finding | evidence | fix | console |', '|---|---|---|---|---|---|---|'); for (const f of list) out.push(`| ${LEVEL_LABEL[f.level]} | ${mdCell(f.rule)} | ${mdCell(f.guideline ?? '')} | ${mdCell(f.title)} | ${mdCell(f.evidence.join(', '))} | ${mdCell(f.fix ?? '')} | ${f.url ? `[open](${f.url})` : ''} |`); out.push(''); } out.push('## Questions only you can answer', '', ...(r.questions.length ? r.questions.map(q => `- [ ] **${STORE_LABEL[q.store]}:** ${q.text}${q.url ? ` ([open](${q.url}))` : ''}`) : ['None open.']), ''); out.push(`storebound ${r.storebound} · ${r.generatedAt}`); return out.join('\n') + '\n'; } /** Short summary line for logs and the MCP result. */ export function summaryText(r: ReportData): string { return shown(r).map(s => { const v = verdictOf(r, s), x = r.summary[s], read = s === 'both' ? undefined : r.read[s]; const extra = [x.warnings ? `${x.warnings} warning(s)` : '', x.blockers && read && !read.checked ? `store not read: ${read.why ?? 'unknown'}` : ''].filter(Boolean).join(', '); return `${STORE_LABEL[s]}: ${v.big}${extra ? ` (${extra})` : ''}`; }).join(' · '); }