- storebound flow list|validate|run: repo-local workflows (store/workflows/*.yml, storebound.workflows.*), writes dry unless --apply, owner steps never run, --from / --resume; init templates for app and game; MCP flow_list + dry flow_run; release-flows skill - storebound listing translate|check|reviewed: per-language listing drafts with store limits and keyword rules, agent or --translate hook, marked until reviewed; MCP listing_translate / listing_check; listing-localization skill - scan: Apple 2.2 beta/demo/test wording rule; scan --originality [--against] - prices export includes subscriptions; prices import skips their rows - site: rejection scan section, Desktop app section with fixture-mode screenshots, Everything else grid, FAQ 4.3 answer - README, CHANGELOG, adapters, API tables
60 lines
4.2 KiB
TypeScript
60 lines
4.2 KiB
TypeScript
// Originality check (App Store Guideline 4.3, Google Play repetitive content) against the owner's other apps. Offline.
|
|
// storebound originality [--against <folder>[,<folder>]] [--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 <folder of another app, or a folder holding several> (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<void> {
|
|
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`);
|
|
}
|