morphit/apps/web/scripts/per-asset-key-family-native-locale-floor-smoke.ts

175 lines
6.4 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-key-family-native-locale-floor-smoke.
*
* Part 122 cp55 STRUCTURAL DEFENSE (LL #59 / O-9).
*
* Generalizes cp54-O8 (which was scoped only to `what_is_<asset>`)
* to multiple per-asset i18n key families that have native-locale
* policy implications per Memory #29.
*
* Covered families (each a Memory #29 native-locale gate):
* - chat.address.address_invalid_<asset> — error-message text
* - chat.address.address_placeholder_<asset> — input placeholder
* - chat.funds_sent.pill_title_<asset> — pill label
* - cheat_sheet.section_assets.<asset> — descriptive entry
* - post_order.form.asset_explainer.<asset> — tooltip text
*
* Excluded families (intentionally — proper-noun byte-identical = correct):
* - chat.address.method_<asset> — bare cryptocurrency name
* - chat.address.pill_method_<asset> — "Name (TICKER)" labels
* for the cp31+ assets where EN itself uses bare ticker label
* (BCH/BTC/XMR-style "X address" entries ARE in scope for those
* specific assets but the smoke doesn't bifurcate; safer to
* leave the whole family out and trust the cp54 lesson generalizes
* to a smoke-per-family approach as drift surfaces.)
*
* Drift history surfaced at cp55:
* - DAI singletons (cp31): EN-fallback across all 3 native locales
* in address_invalid, address_placeholder, pill_title,
* cheat_sheet.section_assets — 12 strings missing.
* - asset_explainer 7-asset drift (cp31-cp49): DAI/ZEC/ARRR/DCR/
* SOL/ETH/XRP all EN-fallback in es/fr/de — 21 strings missing.
*
* Total cp55 closure: 33 native ES/FR/DE strings.
*
* Recurring class scope progression (9 defenses across 8 checkpoints):
* cp48-O1: standalone smoke scripts
* cp49-O2: vitest unit tests
* cp50-O3: HTTP route handler regex
* cp51-O4: ops-cli per-ticker tables
* cp51-O5: per-asset i18n FAQ key coverage
* cp52-O6: Ansible env-template required-vars
* cp53-O7: operator doc per-asset coverage
* cp54-O8: what_is_<asset> FAQ native-locale floor
* cp55-O9: multi-family per-asset native-locale floor (THIS)
*
* Mutation test verification: M-123 — reverting es.json's
* post_order.form.asset_explainer.xrp to EN-fallback fires:
* "per-asset-key-family-native-locale-floor FAILED:
* family `post_order.form.asset_explainer.<asset>`:
* es/xrp is EN-byte-identical (EN-fallback smuggled in)."
*
* Limitation: each covered family must have its own native-vs-EN
* gating logic — proper-noun families (method_<asset>) are
* NOT covered. When a new per-asset family is added with
* native-locale policy implications, add it to FAMILIES below.
*/
import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { ASSET_TICKERS } 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-key-family-native-locale-floor smoke (cp55 LL #59 / O-9) ──\n');
const NATIVE_LOCALES = ['es', 'fr', 'de'] as const;
// Each family entry: path parts (with `{ticker}` substituted at the
// last segment) and a subset of tickers to check. Some families
// (e.g. txid_invalid) only apply to certain tickers; the `tickers`
// option lets us scope the check to those.
interface Family {
name: string;
pathTemplate: string[]; // last segment contains `{ticker}` substitution
tickers?: readonly string[]; // optional subset; default = all 16
tickerLowercase?: boolean; // default true
}
const ALL_TICKERS = ASSET_TICKERS as readonly string[];
const FAMILIES: Family[] = [
{
name: 'chat.address.address_invalid_<asset>',
pathTemplate: ['chat', 'address', 'address_invalid_{ticker}']
},
{
name: 'chat.address.address_placeholder_<asset>',
pathTemplate: ['chat', 'address', 'address_placeholder_{ticker}']
},
{
name: 'chat.funds_sent.pill_title_<asset>',
pathTemplate: ['chat', 'funds_sent', 'pill_title_{ticker}']
},
{
name: 'cheat_sheet.section_assets.<asset>',
pathTemplate: ['cheat_sheet', 'section_assets', '{ticker}']
},
{
name: 'post_order.form.asset_explainer.<asset>',
pathTemplate: ['post_order', 'form', 'asset_explainer', '{ticker}']
}
];
function resolvePath(obj: unknown, parts: string[], ticker: string): unknown {
let cur: unknown = obj;
for (const p of parts) {
const key = p.replace('{ticker}', ticker.toLowerCase());
if (typeof cur !== 'object' || cur === null) return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
const enJson = JSON.parse(
readFileSync(join(REPO_ROOT, 'apps/web/src/lib/i18n/locales/en.json'), 'utf-8')
);
let totalChecks = 0;
const findingsByFamily = new Map<string, string[]>();
for (const family of FAMILIES) {
const tickers = family.tickers ?? ALL_TICKERS;
const findings: string[] = [];
for (const loc of NATIVE_LOCALES) {
const locJson = JSON.parse(
readFileSync(join(REPO_ROOT, `apps/web/src/lib/i18n/locales/${loc}.json`), 'utf-8')
);
for (const ticker of tickers) {
const enVal = resolvePath(enJson, family.pathTemplate, ticker);
const locVal = resolvePath(locJson, family.pathTemplate, ticker);
if (typeof enVal !== 'string' || typeof locVal !== 'string') {
// Family doesn't apply for this ticker; skip
continue;
}
totalChecks++;
if (locVal === enVal) {
findings.push(`${loc}/${ticker}`);
}
}
}
if (findings.length === 0) {
pass(`${family.name}: every covered ticker (${tickers.length}) is native in ${NATIVE_LOCALES.length} locales`);
} else {
findingsByFamily.set(family.name, findings);
}
}
for (const [name, findings] of findingsByFamily) {
fail(
`${name}: native in all locales × tickers`,
`${findings.length} EN-byte-identical: [${findings.slice(0, 10).join(', ')}${findings.length > 10 ? '...' : ''}]`
);
}
const total = passed + failed;
console.log(`\n${passed} passed, ${failed} failed (${total} total)`);
console.log(`Total field-checks across all families: ${totalChecks}`);
if (failed > 0) {
console.error('\nper-asset-key-family-native-locale-floor smoke FAILED');
process.exit(1);
}
console.log(`✓ all ${total} family scenarios passed`);