282 lines
9.8 KiB
TypeScript
282 lines
9.8 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* locale-source-of-truth-smoke.
|
|
*
|
|
* Enforces that the canonical SUPPORTED_LOCALES array in
|
|
* `apps/web/src/lib/i18n/locales.ts` is the single source of truth
|
|
* for "which locales does Morphit ship."
|
|
*
|
|
* BACKGROUND. As of cp141, every smoke and script that needs the
|
|
* supported-locale set either:
|
|
* (a) imports SUPPORTED_LOCALES from $lib/i18n/locales, or
|
|
* (b) reads the on-disk JSON files in apps/web/src/lib/i18n/locales/
|
|
* (the i18n-locale-registry-smoke enforces 1:1 correspondence
|
|
* between the TS array and the JSON files).
|
|
*
|
|
* Before cp141 there were ~12 files with inline locale arrays like:
|
|
* const LOCALES = ['en', 'es', 'de', 'pl', 'fr', 'it', 'ru',
|
|
* 'fa', 'zh-CN', 'zh-HK'];
|
|
* which would silently under-cover any newly-graduated 11th locale.
|
|
*
|
|
* INVARIANT. No file in apps/web/scripts/, scripts/, or apps/web/src/
|
|
* may declare an inline array of locale codes that exactly mirrors
|
|
* the current SUPPORTED_LOCALES set (or a permutation of it).
|
|
*
|
|
* ALLOWED. Intentional SUBSETS for documented backlog-tracking are
|
|
* fine and tracked here:
|
|
*
|
|
* - BACKLOG_LOCALES (long/short-form en-fallback-floor smokes)
|
|
* - NATIVE_LOCALES (per-asset / what-is-asset native-locale-floor)
|
|
*
|
|
* The smoke flags any file that has all 10 codes inline but isn't on
|
|
* the allowlist of structural exceptions below.
|
|
*/
|
|
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import { join, resolve, dirname, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { SUPPORTED_LOCALES } from '../src/lib/i18n/locales';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = resolve(HERE, '../../..');
|
|
|
|
// The current SUPPORTED set as a sorted array for permutation-check.
|
|
const SUPPORTED_SORTED = SUPPORTED_LOCALES.map((l) => l.code).sort();
|
|
|
|
// Files whose locale array is THE source of truth itself — these
|
|
// must NOT be flagged.
|
|
const SOURCE_OF_TRUTH_FILES = new Set([
|
|
'apps/web/src/lib/i18n/locales.ts'
|
|
]);
|
|
|
|
// Files that intentionally hard-code a SUBSET (not the full set).
|
|
// These are allowed because they track specific backlog or native-
|
|
// translation status that's narrower than "all supported."
|
|
const ALLOWED_SUBSET_FILES = new Set([
|
|
'apps/web/scripts/long-form-en-fallback-floor-smoke.ts',
|
|
'apps/web/scripts/short-form-en-fallback-floor-smoke.ts',
|
|
'apps/web/scripts/per-asset-key-family-native-locale-floor-smoke.ts',
|
|
'apps/web/scripts/what-is-asset-faq-native-locale-floor-smoke.ts',
|
|
'apps/web/scripts/native-translations-snapshot-rebuild.ts'
|
|
]);
|
|
|
|
// Files that hard-code the FULL set inline because they CAN'T import
|
|
// SUPPORTED_LOCALES — runtime constraints (no module loader) or
|
|
// cross-workspace boundaries. Each entry below is paired with a
|
|
// rationale; the smoke verifies the inline list MATCHES the canonical
|
|
// set so drift becomes a CI failure rather than a silent miss.
|
|
const PAIRED_UPDATE_FILES: Record<string, string> = {
|
|
// app.html runs in the document <head> before the JS bundle loads,
|
|
// so it can't `import` SUPPORTED_LOCALES. Drift is prevented by
|
|
// this smoke checking the inlined array equals the canonical set.
|
|
'apps/web/src/app.html':
|
|
'Runs in the document <head> before the JS bundle; cannot import.',
|
|
// pushLocalize lives in the indexer workspace which doesn't import
|
|
// from apps/web/. The KNOWN_LOCALES array AND the IndexerPushLocale
|
|
// type union both need to stay in sync — paired-update enforced here.
|
|
'apps/indexer/src/indexer/pushLocalize.ts':
|
|
'Indexer workspace; cannot import from apps/web. Type union + array both must mirror SUPPORTED_LOCALES.'
|
|
};
|
|
|
|
// Roots to walk.
|
|
const SCAN_ROOTS = [
|
|
'apps/web/scripts',
|
|
'apps/web/src',
|
|
'apps/indexer/scripts',
|
|
'apps/indexer/src',
|
|
'apps/relay/scripts',
|
|
'apps/relay/src',
|
|
'apps/mcp-server/scripts',
|
|
'apps/mcp-server/src',
|
|
'scripts'
|
|
];
|
|
|
|
const SKIP_DIRS = new Set([
|
|
'node_modules',
|
|
'.svelte-kit',
|
|
'dist',
|
|
'build',
|
|
'.git',
|
|
'translator-output',
|
|
'.test-output',
|
|
'test-output'
|
|
]);
|
|
|
|
const SKIP_EXT_RE = /\.(d\.ts|d\.mts|d\.cts|json|svg|png|jpg|jpeg|gif|webp|ico|woff2?|ttf|otf)$/;
|
|
|
|
function* walk(dir: string): Generator<string> {
|
|
let entries: string[];
|
|
try {
|
|
entries = readdirSync(dir);
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
if (SKIP_DIRS.has(entry)) continue;
|
|
const full = join(dir, entry);
|
|
let st;
|
|
try {
|
|
st = statSync(full);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (st.isDirectory()) {
|
|
yield* walk(full);
|
|
} else if (st.isFile() && !SKIP_EXT_RE.test(entry)) {
|
|
yield full;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Detection ────────────────────────────────────────────────────
|
|
|
|
// Find every literal-string array in the file. We look for the
|
|
// pattern `[ 'code1', 'code2', ... ]` (or with double-quotes) and
|
|
// parse out the contents.
|
|
const ARRAY_RE = /\[\s*((?:['"][a-zA-Z][a-zA-Z0-9-]*['"]\s*,?\s*){2,})\]/g;
|
|
const TOKEN_RE = /['"]([a-zA-Z][a-zA-Z0-9-]*)['"]/g;
|
|
|
|
function findFullSetArrays(src: string): Array<{ snippet: string; codes: string[] }> {
|
|
const hits: Array<{ snippet: string; codes: string[] }> = [];
|
|
let m: RegExpExecArray | null;
|
|
ARRAY_RE.lastIndex = 0;
|
|
while ((m = ARRAY_RE.exec(src)) !== null) {
|
|
const inner = m[1]!;
|
|
const codes: string[] = [];
|
|
let t: RegExpExecArray | null;
|
|
TOKEN_RE.lastIndex = 0;
|
|
while ((t = TOKEN_RE.exec(inner)) !== null) {
|
|
codes.push(t[1]!);
|
|
}
|
|
// We only care about arrays that match the supported set
|
|
// exactly (as a set). Same length AND same codes.
|
|
if (codes.length !== SUPPORTED_SORTED.length) continue;
|
|
const sortedCodes = [...codes].sort();
|
|
const sameSet = sortedCodes.every((c, i) => c === SUPPORTED_SORTED[i]);
|
|
if (!sameSet) continue;
|
|
hits.push({ snippet: m[0]!.slice(0, 120), codes });
|
|
}
|
|
return hits;
|
|
}
|
|
|
|
// ─── Walk and check ───────────────────────────────────────────────
|
|
|
|
const violations: Array<{ file: string; line: number; snippet: string }> = [];
|
|
const pairedSeen = new Set<string>();
|
|
|
|
for (const root of SCAN_ROOTS) {
|
|
const abs = join(REPO_ROOT, root);
|
|
for (const file of walk(abs)) {
|
|
const rel = relative(REPO_ROOT, file).replace(/\\/g, '/');
|
|
if (SOURCE_OF_TRUTH_FILES.has(rel)) continue;
|
|
if (ALLOWED_SUBSET_FILES.has(rel)) continue;
|
|
let src: string;
|
|
try {
|
|
src = readFileSync(file, 'utf8');
|
|
} catch {
|
|
continue;
|
|
}
|
|
const hits = findFullSetArrays(src);
|
|
if (hits.length === 0) continue;
|
|
// Paired-update files: hits are EXPECTED but verified to match.
|
|
if (rel in PAIRED_UPDATE_FILES) {
|
|
pairedSeen.add(rel);
|
|
continue;
|
|
}
|
|
// Anyone else with full-set inline arrays: violation.
|
|
for (const hit of hits) {
|
|
const idx = src.indexOf(hit.snippet);
|
|
const line = idx >= 0 ? src.slice(0, idx).split('\n').length : 1;
|
|
violations.push({ file: rel, line, snippet: hit.snippet });
|
|
}
|
|
}
|
|
}
|
|
|
|
// Paired-update sanity: every entry in PAIRED_UPDATE_FILES must have
|
|
// been seen. If one is missing, either the file was deleted, the array
|
|
// was removed, OR the array drifted from the canonical set (in which
|
|
// case findFullSetArrays didn't match it, which IS the drift we want
|
|
// to catch).
|
|
const pairedMissing: string[] = [];
|
|
for (const pf of Object.keys(PAIRED_UPDATE_FILES)) {
|
|
if (!pairedSeen.has(pf)) pairedMissing.push(pf);
|
|
}
|
|
|
|
// ─── Report ───────────────────────────────────────────────────────
|
|
|
|
console.log('── locale-source-of-truth smoke ─────────────────────');
|
|
console.log('');
|
|
console.log(
|
|
`Canonical SUPPORTED_LOCALES (${SUPPORTED_SORTED.length}): ${SUPPORTED_SORTED.join(', ')}`
|
|
);
|
|
console.log('');
|
|
|
|
if (violations.length === 0 && pairedMissing.length === 0) {
|
|
console.log(
|
|
' ✓ no file outside the source-of-truth allowlist hardcodes the full SUPPORTED_LOCALES set'
|
|
);
|
|
console.log(
|
|
` ✓ ${pairedSeen.size}/${Object.keys(PAIRED_UPDATE_FILES).length} paired-update files inline the canonical set correctly`
|
|
);
|
|
for (const f of pairedSeen) {
|
|
console.log(` • ${f}`);
|
|
}
|
|
console.log('');
|
|
console.log('2 passed, 0 failed (2 total)');
|
|
console.log('✓ all 2 scenarios passed');
|
|
process.exit(0);
|
|
}
|
|
|
|
let failed = 0;
|
|
|
|
if (violations.length > 0) {
|
|
failed++;
|
|
console.log(` ✗ ${violations.length} hardcoded inline locale array(s) found:`);
|
|
console.log('');
|
|
for (const v of violations) {
|
|
console.log(` - ${v.file}:${v.line}`);
|
|
console.log(` ${v.snippet}…`);
|
|
}
|
|
console.log('');
|
|
console.log(
|
|
'Fix: import SUPPORTED_LOCALES from $lib/i18n/locales (or relative path)'
|
|
);
|
|
console.log(' and derive your local LOCALES array from it, e.g.:');
|
|
console.log(' const LOCALES = SUPPORTED_LOCALES.map((l) => l.code);');
|
|
console.log('');
|
|
console.log(
|
|
'If this is an INTENTIONAL subset, add the file path to ALLOWED_SUBSET_FILES'
|
|
);
|
|
console.log(
|
|
'in apps/web/scripts/locale-source-of-truth-smoke.ts.'
|
|
);
|
|
console.log(
|
|
'If this file cannot import (runtime constraint), add it to PAIRED_UPDATE_FILES'
|
|
);
|
|
console.log('with a rationale comment.');
|
|
}
|
|
|
|
if (pairedMissing.length > 0) {
|
|
failed++;
|
|
console.log('');
|
|
console.log(
|
|
` ✗ ${pairedMissing.length} paired-update file(s) no longer mirror SUPPORTED_LOCALES:`
|
|
);
|
|
for (const pm of pairedMissing) {
|
|
console.log(` - ${pm}`);
|
|
console.log(` Rationale: ${PAIRED_UPDATE_FILES[pm]}`);
|
|
}
|
|
console.log('');
|
|
console.log(
|
|
'These files inline the canonical locale set because they cannot import it.'
|
|
);
|
|
console.log(
|
|
'When SUPPORTED_LOCALES changes, the inline array in each of these files MUST be updated'
|
|
);
|
|
console.log('in the same commit. This smoke is the paired-update enforcement.');
|
|
}
|
|
|
|
console.log('');
|
|
console.log(`${2 - failed} passed, ${failed} failed (2 total)`);
|
|
process.exit(1);
|