#!/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. */ const ZEROED: string[] = []; 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})')`; const PATTERNS: { kind: string; rx: RegExp }[] = [ // >Some visible words< { kind: 'jsx text', rx: />\s*([A-Z][A-Za-z0-9][^<>{}\n]{2,100}?)\s* { 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)); 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(`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) && 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`); console.error('\nUse a catalog key. See docs/localization.md.'); process.exit(1); } console.log(`\ncheck: ok (${ZEROED.length} area(s) held at zero)`); }