morphit/apps/web/scripts/i18n-key-coverage-smoke.ts

255 lines
8.6 KiB
TypeScript

#!/usr/bin/env tsx
/**
* i18n-key-coverage-smoke.
*
* Walks the entire `apps/web/src/` tree, finds every static
* `$_('foo.bar')` reference and every dynamic
* `$_(`foo.${var}`)` prefix, and verifies:
*
* 1. Every static key resolves to a string in en.json.
* 2. Every dynamic-key prefix resolves to a non-empty
* object in en.json.
*
* This catches the class of bug where a developer added new
* `$_(...)` references in code but forgot to add the
* corresponding strings to the locale files — the user sees
* raw key strings like `onboarding.import.posting_only.error.bad_account`
* instead of human-readable copy.
*
* Cross-locale parity is enforced by `voucher-locale-parity-smoke`
* for specific keys; the existing in-repo audit elsewhere ensures
* non-en locales stay in sync with en. This smoke just enforces
* that en (the source-of-truth) covers everything code references.
*
* Known false-positive avoidance:
* - `some.key` in `lib/utils/splitOnPlaceholder.ts` is a
* literal example string in a code comment / test path,
* not a real i18n reference. Hardcoded ignore list.
* - Dynamic-template prefixes whose values are bounded by a
* small enum (e.g. `home.points.${point.key}.title` where
* `point.key` ranges over a fixed list) are validated by
* spot-checking that the prefix object exists; the smoke
* does not enumerate every possible substitution.
*
* Usage:
* cd apps/web && npx tsx scripts/i18n-key-coverage-smoke.ts
*/
import { readFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import { execSync } from 'node:child_process';
const REPO = join(import.meta.dirname, '..');
const SRC = join(REPO, 'src');
const EN_JSON_PATH = join(SRC, 'lib/i18n/locales/en.json');
/** Keys to ignore — known-safe false positives. */
const IGNORE_KEYS = new Set<string>([
// Literal example string in test/utility code, not a real
// i18n reference.
'some.key'
]);
/** Dynamic-key prefixes to ignore — these are intentionally
* scoped narrowly elsewhere, or use values that aren't worth
* enumerating in this smoke. */
const IGNORE_DYNAMIC_PREFIXES = new Set<string>([]);
interface KeyOccurrence {
readonly key: string;
readonly file: string;
readonly dynamic: boolean;
/** For dynamic occurrences only: the static portion of
* the leaf-key name immediately following the parent
* path's last dot, before the runtime substitution.
* Example: in `foo.bar.step${n}_title`, parent path is
* `foo.bar` and leafPrefix is `step`. Empty string for
* pure dot-prefixed substitutions like `foo.bar.${x}.baz`
* (caller validates by checking `foo.bar` is an object). */
readonly leafPrefix?: string;
}
function findSourceFiles(): string[] {
// Use find so we don't pull in glob etc. Covers .ts and
// .svelte under apps/web/src/.
const out = execSync(`find "${SRC}" -type f \\( -name '*.ts' -o -name '*.svelte' \\)`, {
encoding: 'utf8'
});
return out
.trim()
.split('\n')
.filter((s) => s.length > 0);
}
function extractKeysFromFile(filepath: string): KeyOccurrence[] {
const src = readFileSync(filepath, 'utf8');
const occurrences: KeyOccurrence[] = [];
// Static: $_('foo.bar') or $_("foo.bar")
for (const m of src.matchAll(/\$_\(\s*['"]([\w.]+)['"]/g)) {
const key = m[1]!;
if (!IGNORE_KEYS.has(key)) {
occurrences.push({
key,
file: relative(REPO, filepath),
dynamic: false
});
}
}
// Dynamic: $_(`foo.${...}`) — the smoke's responsibility
// is to verify the static prefix IS surrounded by enough
// keys that resolve. Two cases:
//
// 1. Dot-prefixed substitution like `foo.bar.${x}.baz` —
// the prefix `foo.bar` (everything up to and
// including the last dot before `${`) must resolve
// to a non-empty object.
//
// 2. Non-dot substitution like `foo.bar.step${n}_title`
// — `${n}` is part of a leaf key name, not a path
// separator. The prefix is `foo.bar` (the parent
// object containing the synthesized leaf keys).
// We can't enumerate every value of `${n}`, so we
// validate that the parent object exists and has
// AT LEAST ONE leaf key whose name starts with the
// static prefix immediately following the last dot.
//
// Implementation: split on the last dot before `${`.
// Everything before that dot is the parent path; the
// segment after is a "leaf prefix" the runtime substitutes
// into.
for (const m of src.matchAll(/\$_\(\s*`([^`]+)`/g)) {
const tpl = m[1]!;
if (!tpl.includes('${')) continue;
const beforeFirstSub = tpl.split('${')[0]!;
// Find the last `.` in the static portion before the
// first substitution. Everything before that dot is
// the parent path; everything after is a leaf-name
// prefix that the runtime extends with the
// substitution value.
const lastDot = beforeFirstSub.lastIndexOf('.');
if (lastDot === -1) continue;
const parentPath = beforeFirstSub.slice(0, lastDot);
const leafPrefix = beforeFirstSub.slice(lastDot + 1);
if (IGNORE_DYNAMIC_PREFIXES.has(parentPath)) continue;
occurrences.push({
key: parentPath,
file: relative(REPO, filepath),
dynamic: true,
leafPrefix
});
}
return occurrences;
}
function lookup(d: unknown, key: string): unknown {
const parts = key.split('.');
let cur = d;
for (const p of parts) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
function main(): void {
console.log('i18n-key-coverage smoke:\n');
const en = JSON.parse(readFileSync(EN_JSON_PATH, 'utf8'));
const files = findSourceFiles();
let allOccurrences: KeyOccurrence[] = [];
for (const f of files) {
allOccurrences = allOccurrences.concat(extractKeysFromFile(f));
}
const staticKeys = new Map<string, string[]>();
const dynamicEntries = new Map<string, { files: string[]; leafPrefixes: Set<string> }>();
for (const o of allOccurrences) {
if (o.dynamic) {
const e = dynamicEntries.get(o.key) ?? {
files: [],
leafPrefixes: new Set<string>()
};
e.files.push(o.file);
e.leafPrefixes.add(o.leafPrefix ?? '');
dynamicEntries.set(o.key, e);
} else {
const arr = staticKeys.get(o.key) ?? [];
arr.push(o.file);
staticKeys.set(o.key, arr);
}
}
let failures = 0;
let scenarios = 0;
// Scenario 1: every static key resolves to a string in en.json.
scenarios++;
const missingStatic: string[] = [];
for (const [key, files] of staticKeys) {
const v = lookup(en, key);
if (typeof v !== 'string') {
missingStatic.push(`${key} (in ${[...new Set(files)][0]})`);
}
}
if (missingStatic.length === 0) {
console.log(` ✓ all ${staticKeys.size} static keys resolve to strings in en.json`);
} else {
console.log(`${missingStatic.length} static keys missing in en.json:`);
for (const m of missingStatic) console.log(` ${m}`);
failures++;
}
// Scenario 2: every dynamic-key parent path resolves to
// a non-empty object in en.json, AND if the occurrence
// uses a non-empty leafPrefix (meaning the runtime
// substitutes into a leaf-key name like `step${n}_title`),
// at least one leaf key in the object has that prefix.
scenarios++;
const missingDynamic: string[] = [];
for (const [parentPath, entry] of dynamicEntries) {
const v = lookup(en, parentPath);
if (v === undefined || v === null || typeof v !== 'object' || Array.isArray(v)) {
missingDynamic.push(
`${parentPath} (parent path missing or not an object) in ${[...new Set(entry.files)][0]}`
);
continue;
}
const objKeys = Object.keys(v as Record<string, unknown>);
if (objKeys.length === 0) {
missingDynamic.push(
`${parentPath} (parent path is empty object) in ${[...new Set(entry.files)][0]}`
);
continue;
}
// For each non-empty leafPrefix, require at least
// one matching leaf key.
for (const leafPrefix of entry.leafPrefixes) {
if (leafPrefix === '') continue; // pure ${x}.foo case — parent existing is enough
const matches = objKeys.some((k) => k.startsWith(leafPrefix));
if (!matches) {
missingDynamic.push(
`${parentPath}.${leafPrefix}<...> (no leaf key matches prefix) in ${[...new Set(entry.files)][0]}`
);
}
}
}
if (missingDynamic.length === 0) {
console.log(` ✓ all ${dynamicEntries.size} dynamic-key parent paths resolve correctly`);
} else {
console.log(
`${missingDynamic.length} dynamic-key parent paths missing or empty in en.json:`
);
for (const m of missingDynamic) console.log(` ${m}`);
failures++;
}
console.log(
`\n${failures === 0 ? '✓ all' : '✗'} ${scenarios - failures}${failures === 0 ? '' : '/' + scenarios} scenarios passed`
);
process.exit(failures === 0 ? 0 : 1);
}
main();