#!/usr/bin/env bun /** * Counts candidate hardcoded user-facing strings in the web UI. * * Two jobs. It sizes the localization work (docs/localization.md quotes its output), and once an area * reaches zero it becomes the guard that stops the debt coming back — see ZEROED below. * * It is a heuristic and it is deliberately a FLOOR. It cannot see multi-line JSX text, ternaries * inside JSX, strings passed down as props, or anything a plugin renders. A falling number means * progress; a zero means "nothing this scanner can see", not "nothing left". * * bun scripts/i18n/scan-hardcoded.ts # summary by area * bun scripts/i18n/scan-hardcoded.ts --files # worst files first * bun scripts/i18n/scan-hardcoded.ts --list ui.button # print the strings themselves * bun scripts/i18n/scan-hardcoded.ts --check # non-zero exit if a zeroed area regressed */ import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; const AREAS = [ { label: 'officer-web (shell)', root: 'src/apps/officer-web' }, { label: 'officerdev (panels)', root: 'src/workspaces/officerdev/src' }, { label: 'shared components', root: 'src/workspaces/components' }, ] as const; /** * Areas that have been converted and must stay converted. Add an area here the moment it reaches * zero — enabling every area on day one would just be red for weeks and get ignored. * * 'shared components' landed 2026-08-23 (localization phase 1). It holds three `new Error()` messages * that must NEVER be translated; they are reported but excluded from the check, which is why the * neverTranslate split below has to exist for this entry to be possible at all. */ const ZEROED: string[] = ['shared components']; const SKIP = /node_modules|\.gen\.|[/\\]locales[/\\]|[/\\]vendor[/\\]|\.test\.|\.d\.ts$/; // 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})')`; /** * `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; } function* walk(dir: string): Generator { let entries: string[]; try { entries = readdirSync(dir); } catch { return; } for (const entry of entries) { const path = join(dir, entry); if (SKIP.test(path)) continue; let isDir = false; try { isDir = statSync(path).isDirectory(); } catch { continue; } if (isDir) yield* walk(path); else if (path.endsWith('.tsx') || path.endsWith('.ts')) yield path; } } type Hit = { kind: string; value: string; file: string }; function scan(root: string): { files: number; hits: Hit[] } { const hits: Hit[] = []; let files = 0; for (const file of walk(root)) { files++; let source: string; try { source = readFileSync(file, 'utf8'); } catch { continue; } for (const { kind, rx } of PATTERNS) { rx.lastIndex = 0; for (const match of source.matchAll(rx)) { const value = match.slice(1).find((group) => group !== undefined); if (isHuman(value)) hits.push({ kind, value: value.trim(), file }); } } } return { files, hits }; } const args = new Set(process.argv.slice(2)); const listPrefix = process.argv.includes('--list') ? process.argv[process.argv.indexOf('--list') + 1] : undefined; 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)}`, ); 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)}`); } 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)); 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}`); if (args.has('--files')) { console.log('\nworst files:'); const perFile = new Map(); for (const h of all) perFile.set(h.file, (perFile.get(h.file) ?? 0) + 1); for (const [file, n] of [...perFile].sort((a, b) => b[1] - a[1]).slice(0, 25)) { console.log(` ${pad(n, 4)} ${file}`); } } if (listPrefix) { console.log(`\nstrings in files matching "${listPrefix}":`); for (const h of all.filter((x) => x.file.includes(listPrefix))) console.log(` [${h.kind}] ${h.value}`); } if (args.has('--check')) { 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 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); } console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`); }