diff --git a/scripts/i18n/scan-hardcoded.ts b/scripts/i18n/scan-hardcoded.ts index 07d9ffe7..c7b1f060 100755 --- a/scripts/i18n/scan-hardcoded.ts +++ b/scripts/i18n/scan-hardcoded.ts @@ -34,21 +34,36 @@ const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\ // A quoted literal, single or double. Kept as two groups because JS has no branch reset. const Q = String.raw`(?:"([^"\n]{2,120})"|'([^'\n]{2,120})')`; -const PATTERNS: { kind: string; rx: RegExp }[] = [ +/** + * `neverTranslate` marks a category the doc's "What must never be translated" list forbids converting — + * so it is reported (an agent should see it and confirm), but excluded from `--check`. + * + * Without that split the guard is unusable: an area holding one legitimate `new Error()` can never + * reach zero, so it could never be added to ZEROED, so the debt guard could never be switched on for + * it. Counting work that must NOT be done against a target of zero makes the target unreachable. + */ +const PATTERNS: { kind: string; rx: RegExp; neverTranslate?: true }[] = [ // >Some visible words< { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s* /^[a-z0-9:_\-[\]().#&/%!]+$/.test(tok); + if (tokens.every(utilityish) && tokens.some((tok) => /[:[]/.test(tok))) return false; return true; } @@ -118,7 +143,9 @@ const results = AREAS.map((area) => ({ ...area, ...scan(area.root) })); const kinds = PATTERNS.map((p) => p.kind); const pad = (s: string | number, n: number) => String(s).padStart(n); -console.log(`${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`); +console.log( + `${'area'.padEnd(22)} ${pad('files', 5)} ${kinds.map((k) => pad(k.slice(0, 13), 14)).join('')} ${pad('total', 7)}`, +); for (const r of results) { const byKind = kinds.map((k) => pad(r.hits.filter((h) => h.kind === k).length, 14)).join(''); console.log(`${r.label.padEnd(22)} ${pad(r.files, 5)} ${byKind} ${pad(r.hits.length, 7)}`); @@ -128,9 +155,19 @@ const all = results.flatMap((r) => r.hits); const distinct = new Set(all.map((h) => h.value)); const interpolated = new Set(all.filter((h) => h.kind === 'template str').map((h) => h.value)); -console.log(`${'TOTAL'.padEnd(22)} ${pad(results.reduce((n, r) => n + r.files, 0), 5)} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`); +const NEVER_KINDS = new Set(PATTERNS.filter((p) => p.neverTranslate).map((p) => p.kind)); +const convertible = (hits: Hit[]) => hits.filter((h) => !NEVER_KINDS.has(h.kind)); + +console.log( + `${'TOTAL'.padEnd(22)} ${pad( + results.reduce((n, r) => n + r.files, 0), + 5, + )} ${kinds.map((k) => pad(all.filter((h) => h.kind === k).length, 14)).join('')} ${pad(all.length, 7)}`, +); console.log(); console.log(`occurrences ${all.length}`); +console.log(` convertible ${convertible(all).length}`); +console.log(` never-translate ${all.length - convertible(all).length} (${[...NEVER_KINDS].join(', ')})`); console.log(`distinct strings ${distinct.size}`); console.log(` needing params ${interpolated.size}`); @@ -149,10 +186,16 @@ if (listPrefix) { } if (args.has('--check')) { - const regressed = results.filter((r) => ZEROED.includes(r.label) && r.hits.length > 0); + const regressed = results + .filter((r) => ZEROED.includes(r.label)) + .map((r) => ({ label: r.label, hits: convertible(r.hits) })) + .filter((r) => r.hits.length > 0); if (regressed.length > 0) { console.error('\nThese areas were converted and have regressed:'); - for (const r of regressed) console.error(` ${r.label}: ${r.hits.length} hardcoded strings`); + for (const r of regressed) { + console.error(` ${r.label}: ${r.hits.length} hardcoded strings`); + for (const h of r.hits) console.error(` [${h.kind}] ${h.value} — ${h.file}`); + } console.error('\nUse a catalog key. See docs/localization.md.'); process.exit(1); }