morphit/apps/web/scripts/per-asset-mandatory-family-i18n-parity-smoke.ts

231 lines
9.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env tsx
/**
* per-asset-mandatory-family-i18n-parity smoke — Part 122 cp75 (LL #75 / O-24).
*
* Generalises cp51-O5 (faq-per-tradable-asset-parity) from one
* family (`faq.entries.what_is_<asset>`) to the full set of
* MANDATORY-when-asset-is-tradable i18n key families.
*
* cp51-O5 caught the cp21/cp24/cp27 drift class — new ticker added
* to the registry but the FAQ family forgotten in some locales.
* Since then we've added 12 more assets, AND we've added several
* NEW per-asset families (privacy guides, cheat sheet, asset
* explainer). cp55-O9 polices the EN-vs-native-byte-identical
* floor for several of these families. cp74-O22 polices route
* SEO presence across all locales. This smoke fills the remaining
* gap: structural presence of MANDATORY per-asset families across
* all 10 locales, driven by the asset registry.
*
* Failure mode caught:
* - cp NN adds ticker FOO to packages/asset-registry.
* - Developer adds `faq.entries.what_is_foo` (cp51-O5 happy),
* and adds `privacy.guides.foo.intro` to en.json,
* but FORGETS the other 9 locales for some family.
* - i18n-locale-parity-smoke catches "key in en but not in fr",
* so that's covered.
* - But if the developer ALSO forgets en.json itself for one
* family (e.g. cheat_sheet.section_assets.foo), parity smoke
* can't catch what's not in en — only THIS registry-driven
* smoke catches "registry says FOO is tradable but its
* mandatory family is absent from en.json".
*
* MANDATORY families verified (each has a render site that is
* NOT wrapped in a conditional fallback — absence is a user-
* facing bug):
*
* 1. post_order.form.asset_explainer.<ticker>
* Used by apps/web/src/routes/[lang]/post-order/+page.svelte
* as the asset-specific tooltip.
* 2. cheat_sheet.section_assets.<ticker>
* Used by the asset cheat sheet for each tradable asset.
* 3. privacy.guides.<ticker>.one_line
* Used by privacy guide index card.
* 4. privacy.guides.<ticker>.intro
* Used by the asset privacy guide body.
* 5. privacy.guides.<ticker>.meta_description
* Used as the <meta description> tag for the asset guide page.
*
* OPTIONAL families (deliberately NOT enforced):
*
* - privacy.guides.<ticker>.caveats
* Renderer at apps/web/src/routes/[lang]/privacy/[asset]/+page.svelte:167
* checks `$_(key) !== key` and skips the section if the key
* is absent. Chains with nothing privacy-critical to caveat
* (XMR, BTC, DAI, BCH, LTC at cp75) deliberately have no
* caveats entry. Adding them here would force-create no-op
* caveats; that defeats the design.
*
* Adding a new mandatory family: append to MANDATORY_FAMILIES.
* Adding a new optional family: document it in this header so
* future maintainers know the family was considered.
*
* Mutation test M-147:
* - Delete `post_order.form.asset_explainer.xrp` from en.json
* → smoke fires naming the family + ticker + locale.
* - Restore → smoke passes.
*
* Recurring class scope progression (registry-driven i18n parity):
* cp51-O5: faq.entries.what_is_<asset> (1 family × all tickers × all locales)
* cp74-O22: seo.<route>.{title,description} (1 registry × all routes × all locales)
* cp75-O24: 5 per-asset mandatory families (5 families × all tickers × all locales)
*/
import { readFileSync, readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ASSET_TICKERS, isGoodsAsset, type AssetTicker } from '../../../packages/asset-registry/src/index';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const REPO_ROOT = join(__dirname, '..', '..', '..');
let failed = 0;
let passed = 0;
function pass(name: string): void { console.log(`${name}`); passed++; }
function fail(name: string, detail: string): void {
console.error(`${name}`); console.error(` ${detail}`); failed++;
}
console.log('\n── per-asset-mandatory-family-i18n-parity smoke (cp75 LL #75 / O-24) ──\n');
/**
* Each mandatory family. `pathTemplate` uses `{ticker}` as the
* placeholder for the lowercase ticker (asset registry is the
* authority for casing — tickers are lowercase in i18n keys
* even though they're uppercase in the registry constant).
*/
interface Family {
readonly id: string;
readonly pathTemplate: string;
readonly renderSite: string;
/** cp425 — true for families that only make sense for a crypto asset
* (e.g. the per-asset PRIVACY GUIDE). Goods assets (BARTER) have no
* on-chain privacy guide, so these are skipped for them via isGoodsAsset.
* Non-crypto-specific families (asset_explainer, cheat sheet) apply to
* goods too and stay required. */
readonly cryptoOnly?: boolean;
}
const MANDATORY_FAMILIES: Family[] = [
{
id: 'asset_explainer',
pathTemplate: 'post_order.form.asset_explainer.{ticker}',
renderSite: 'apps/web/src/routes/[lang]/post-order/+page.svelte (post-order asset tooltip)'
},
{
id: 'cheat_sheet_section_assets',
pathTemplate: 'cheat_sheet.section_assets.{ticker}',
renderSite: 'apps/web/src/lib/cheatsheet (per-asset cheat-sheet block)'
},
{
id: 'privacy_guide_one_line',
pathTemplate: 'privacy.guides.{ticker}.one_line',
renderSite: 'apps/web/src/routes/[lang]/privacy/+page.svelte (privacy index card)',
cryptoOnly: true
},
{
id: 'privacy_guide_intro',
pathTemplate: 'privacy.guides.{ticker}.intro',
renderSite: 'apps/web/src/routes/[lang]/privacy/[asset]/+page.svelte (guide body)',
cryptoOnly: true
},
{
id: 'privacy_guide_meta_description',
pathTemplate: 'privacy.guides.{ticker}.meta_description',
renderSite: 'apps/web/src/routes/[lang]/privacy/[asset]/+page.svelte (HTML meta description)',
cryptoOnly: true
}
];
/** Resolve a dot-path against a nested JSON object. Returns the
* value if it's a non-empty string, otherwise null. */
function resolveKey(obj: unknown, path: string): string | null {
const parts = path.split('.');
let cur: unknown = obj;
for (const part of parts) {
if (typeof cur !== 'object' || cur === null) return null;
cur = (cur as Record<string, unknown>)[part];
}
return typeof cur === 'string' && cur.length > 0 ? cur : null;
}
const localesDir = join(REPO_ROOT, 'apps/web/src/lib/i18n/locales');
const localeFiles = readdirSync(localesDir)
.filter((f) => f.endsWith('.json'))
.map((f) => f.replace('.json', ''))
.sort();
console.log(`▸ Checking ${MANDATORY_FAMILIES.length} families × ${ASSET_TICKERS.length} tickers × ${localeFiles.length} locales`);
console.log(` = ${MANDATORY_FAMILIES.length * ASSET_TICKERS.length * localeFiles.length} key resolutions\n`);
interface Missing {
readonly family: string;
readonly ticker: string;
readonly locale: string;
readonly path: string;
readonly renderSite: string;
}
const missing: Missing[] = [];
// Pre-load all locale JSONs once.
const locales = new Map<string, unknown>();
for (const loc of localeFiles) {
try {
locales.set(loc, JSON.parse(readFileSync(join(localesDir, `${loc}.json`), 'utf-8')));
} catch (e) {
fail(`${loc}.json parses as JSON`, String((e as Error).message));
}
}
for (const family of MANDATORY_FAMILIES) {
for (const tickerUpper of ASSET_TICKERS) {
// cp425 — skip crypto-only families (privacy guides) for goods
// assets (BARTER): a barter listing has no on-chain privacy guide.
if (family.cryptoOnly && isGoodsAsset(tickerUpper as AssetTicker)) continue;
const ticker = tickerUpper.toLowerCase();
const path = family.pathTemplate.replace('{ticker}', ticker);
for (const locale of localeFiles) {
const json = locales.get(locale);
if (json === undefined) continue;
if (resolveKey(json, path) === null) {
missing.push({
family: family.id,
ticker,
locale,
path,
renderSite: family.renderSite
});
}
}
}
}
if (missing.length === 0) {
pass(`every mandatory family × ticker × locale resolves to a non-empty string`);
} else {
// Group by family for readability.
const byFamily = new Map<string, Missing[]>();
for (const m of missing) {
const list = byFamily.get(m.family) ?? [];
list.push(m);
byFamily.set(m.family, list);
}
for (const [familyId, entries] of byFamily.entries()) {
const sample = entries.slice(0, 5).map((e) => `${e.locale}: ${e.path}`).join(', ');
const more = entries.length > 5 ? ` (and ${entries.length - 5} more)` : '';
fail(
`family ${familyId} has all ticker × locale entries`,
`${entries.length} missing — ${sample}${more}. Render site: ${entries[0]!.renderSite}`
);
}
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
if (failed > 0) {
console.error('\nper-asset-mandatory-family-i18n-parity smoke FAILED');
console.error('Memory rule: locale parity — every user-facing string change must be translated into all 10 locales in the same turn.');
console.error('When adding a new ticker to packages/asset-registry, every mandatory family must ship its keys in every locale.');
process.exit(1);
}
console.log(`✓ all ${total} per-asset-mandatory-family-i18n-parity scenarios passed`);