// Originality check (App Store Guideline 4.3, Google Play repetitive content) against the owner's other apps. Offline. // storebound originality [--against [,]] [--threshold 0.7] [--json] // --against (or originality.against in the config): folders with a storebound.config.ts, or workspace folders that // hold several apps one level down. Per other app: listing text, name, icon and screenshot perceptual hashes, project // structure (file layout and identical files), an overall score, why a pair is flagged, and what to change. // Exit 1 when a pair is flagged as a near-duplicate. The scoring is in cli/lib/originality.ts; the judgement // (mechanic, content, differentiator sentence) is the portfolio-originality skill. import { realpathSync } from 'node:fs'; import { relative } from 'node:path'; import { loadConfig, type ShipConfig } from './lib/config'; import type { Ctx } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { type AppFingerprint, DEFAULT_THRESHOLD, findApps, fingerprint, type OriginalityReport, report } from './lib/originality'; import { textTable } from './lib/table'; const TAG = 'originality'; /** The 4.3 comparison of one app against the apps in `against` (also used by `storebound scan --originality`). */ export async function compareAgainst(o: { config: ShipConfig; root: string; resolve(p: string): string }, against: string[], thresholdArg?: number, onSkip: (msg: string) => void = () => {}): Promise<{ report: OriginalityReport; me: AppFingerprint; others: number }> { const { config, root, resolve } = o; const folders = (against.length ? against : config.originality?.against ?? []).map(f => resolve(f)); if (!folders.length) throw new Error('nothing to compare with: --against (or originality.against in the config)'); const threshold = thresholdArg ?? config.originality?.threshold ?? DEFAULT_THRESHOLD; if (!(threshold > 0 && threshold < 1)) throw new Error('--threshold: between 0 and 1'); const self = realpathSync(root); const configs = findApps(folders.map(f => { try { return realpathSync(f); } catch { return f; } }), self); if (!configs.length) throw new Error(`no other app (storebound.config.ts) in ${folders.map(f => relative(root, f) || '.').join(', ')}`); const me = fingerprint(config, root); const others = []; for (const p of configs) { try { const l = await loadConfig(p); others.push(fingerprint(l.config, l.root)); } catch (e) { onSkip(`${p}: skipped (${(e as Error).message.split('\n')[0]?.slice(0, 160)})`); } } return { report: report(me, others, threshold), me, others: configs.length }; } export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const json = a.has('json'); const { report: r, me, others } = await compareAgainst(ctx.loaded, a.list('against'), a.has('threshold') ? a.num('threshold', DEFAULT_THRESHOLD) : undefined, m => warn(TAG, m)); if (!json) log(TAG, `${config.app.name} vs ${others} app(s), offline`); const threshold = r.threshold; if (json) { printJson(r); if (r.flagged) process.exitCode = 1; return; } for (const n of me.notes) warn(TAG, `${config.app.name}: ${n}`); const pct = (x: number | null) => (x === null ? '-' : `${Math.round(x * 100)}%`); console.log(textTable(['app', 'overall', 'name', 'text', 'icon', 'screens', 'structure', ''], r.pairs.map(p => [p.other.name, pct(p.overall), pct(p.axes.name.score), pct(p.axes.text.score), pct(p.axes.icon.score), pct(p.axes.screenshots.score), pct(p.axes.structure.score), p.flagged ? 'NEAR-DUPLICATE' : '']))); for (const p of r.pairs) { if (!p.flagged && !p.suggestions.length) continue; console.log(`\n${p.other.name} (${relative(root, p.other.dir) || '.'})${p.flagged ? ': flagged' : ''}`); for (const why of p.reasons) console.log(` - ${why}`); for (const s of p.suggestions) console.log(` > ${s}`); } for (const o of r.others) for (const n of o.notes) log(TAG, `${relative(root, o.dir) || '.'}: ${n}`); if (r.flagged) { warn(TAG, `${r.flagged} near-duplicate(s) at threshold ${threshold}: see the portfolio-originality skill before submitting`); process.exitCode = 1; } done(TAG, `${r.pairs.length} pair(s) compared, ${r.flagged} flagged`); }