Files
orca/config/scripts/verify-localization-extraction.mjs
T
76d4b01969 chore(i18n): gate unreferenced catalog keys in the extraction check and retire 1,644 of them (#22056)
* chore(i18n): add a detector for catalog keys no source references

A key can leave the code without leaving the catalog: a feature is reverted, a
setting is rewritten in place, a component is deleted. Nothing fails, so the
entries stay. Every locale carries them, translators spend effort on strings no
screen renders, and coverage numbers understate how much is actually translated.

`verify:localization-extraction` already reports an unreferenced set, but it
cannot be deleted as-is: i18next-cli does not see a key held in a data structure
(`{ key: '…' }`) or passed as a variable, and 18 files do exactly that. So this
requires two independent conditions — extraction misses the key AND the exact
string appears in no source literal under src/ or mobile/.

Two properties matter and both were found the hard way:

- Plural variants are judged by their base key. i18next appends the CLDR
  category at runtime, so `…_one` never appears in a source file even though the
  base does. Without this the scan eats every plural in the catalog.
- The scan is iterative. Removing a base key can strand the variants that
  survived on its account, so it must be re-run until it reports none.

Registered as `verify:localization-orphans` but deliberately left out of the
lint chain: it reports 1,401 keys on current main, so gating now would fail CI.
The prune and the gate come together in a follow-up.

* test(i18n): cover the orphan detector's two non-obvious rules

Both rules decide whether a key is live, and both are easy to break without
noticing: the plural rule keeps `…count_one` when only `…count` appears in
source, and the literal scan is the sole evidence for keys extraction cannot
see — held in a data structure or passed as a variable.

Extracting them as pure functions changes no behaviour: the detector still
reports 14,459 keys, 12,887 referenced, 1,401 orphans, and --check still
exits 1.

* chore(i18n): rename the detector's key pattern constant

main gained the anti-slop rule no-shape-in-symbol-names after this branch
was cut, and KEY_SHAPED trips it once the branch is merged with main.
Renamed to CATALOG_KEY_PATTERN; no behaviour change.

* chore(i18n): name the orphan detector like the other localization verifiers

CODEOWNERS routes /config/scripts/*localization*.mjs to the localization owner, and find-orphaned-catalog-keys.mjs matched neither that pattern nor *locale*, so a change to it asked nobody for review. The file now carries the name of the npm script that runs it, verify:localization-orphans.

* fix(i18n): report stranded plural variants in the same pass as their base key

isKeyReferenced kept a plural variant alive whenever en.json still carried its base key, even when no source reached that base key. The base key was reported and the variants were not, so a prune needed a second run to find them. Only a base key that extraction or a source literal references keeps its variants now; the --check message drops the re-run advice and names the override tables, which sit outside the scan roots.

* fix(i18n): let the literal scan accept every key-shaped literal

The scan captured only literals of seven characters or more and then applied CATALOG_KEY_PATTERN, which has no such floor. A live key shorter than that, held in a data structure, would have been reported as an orphan. en.json has no key that short today, so the result is unchanged; the capture now leaves the shape decision to the one pattern.

* fix(i18n): stop the orphan scan when a source root cannot be read

collectSourceFiles swallowed every readdir error, so a directory it could not read contributed no literals and a key named only there would have been reported as safe to delete. Only a missing mobile/ root is skipped now, since a checkout without the mobile app is still a complete desktop scan; any other error stops the scan.

* style(i18n): wrap one line of the orphan detector to the formatter's width

* chore(i18n): fold the orphan-key detector into the extraction check and retire 1,644 unreferenced keys

verify-localization-extraction.mjs now filters its own unreferenced set by
source literals (src/ and mobile/), plural base keys and dynamic prefixes
composed inside translate calls, and fails when any key is named nowhere.
That replaces the standalone verify-localization-orphans.mjs script and its
second extraction pass.

The prune is recomputed on current main: 1,644 keys leave all seven locale
catalogs, 285 stale entries leave the key-override tables, and
en-runtime-required.json is regenerated.

Supersedes #19801.

Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>

* chore(i18n): scope the unused-key scan to src/ and require a dotted dynamic prefix

mobile/ edits do not trigger the extraction check, so literals there must not
decide its verdict; a dotless runtime-built prefix would keep unrelated keys alive.

Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Evgenii <413011+smwbev@users.noreply.github.com>
2026-10-27 20:29:43 -07:00

207 lines
7.4 KiB
JavaScript

import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { pathToFileURL } from 'node:url'
import { collectSourceFiles } from './verify-localization-catalog.mjs'
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
// Extraction misses keys held in data structures or passed as variables, so any literal naming a key keeps it.
// Scoped to src/ so only changes that trigger this check can change its verdict.
const KEY_LITERAL_SCAN_ROOT = 'src'
const KEY_LITERAL_RE = /['"`]([A-Za-z][\w-]*(?:\.[\w-]+)+)['"`]/g
const DYNAMIC_KEY_HEAD_RE =
/\b(?:t|translate\w*)\(\s*(?:`([A-Za-z][\w-]*\.[\w.-]*)\$\{|['"]([A-Za-z][\w-]*\.[\w.-]*)['"]\s*\+)/g
// i18next resolves `key_one` etc. from the base key at runtime.
const PLURAL_SUFFIX_RE = /_(?:zero|one|two|few|many|other)$/
function flattenCatalog(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
function placeholders(value) {
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
}
export function compareExtraction(extractedCatalog, englishCatalog) {
const extracted = flattenCatalog(extractedCatalog)
const english = flattenCatalog(englishCatalog)
const dynamicDefaults = [...extracted.entries()]
.filter(([, value]) => value.length === 0)
.map(([key]) => key)
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
const fallbackDrift = []
const placeholderMismatches = []
for (const [key, extractedValue] of extracted) {
const englishValue = english.get(key)
if (
extractedValue.length === 0 ||
englishValue === undefined ||
englishValue === extractedValue
) {
continue
}
fallbackDrift.push(key)
if (placeholders(extractedValue) !== placeholders(englishValue)) {
placeholderMismatches.push(key)
}
}
return {
extracted,
english,
dynamicDefaults,
missingFromEnglish,
orphans,
fallbackDrift,
placeholderMismatches
}
}
export function collectKeyLiterals(sourceText, evidence) {
for (const match of sourceText.matchAll(KEY_LITERAL_RE)) {
evidence.literals.add(match[1])
}
for (const match of sourceText.matchAll(DYNAMIC_KEY_HEAD_RE)) {
evidence.dynamicPrefixes.add(match[1] ?? match[2])
}
return evidence
}
async function collectSourceKeyEvidence(root) {
const evidence = { literals: new Set(), dynamicPrefixes: new Set() }
for (const file of await collectSourceFiles(root, path.join(root, KEY_LITERAL_SCAN_ROOT))) {
collectKeyLiterals(await fs.readFile(file, 'utf8'), evidence)
}
return evidence
}
export function findUnreferencedKeys(orphans, extracted, { literals, dynamicPrefixes }) {
const isNamed = (key) =>
extracted.has(key) ||
literals.has(key) ||
[...dynamicPrefixes].some((prefix) => key.startsWith(prefix))
return orphans.filter((key) => {
if (isNamed(key)) {
return false
}
const base = key.replace(PLURAL_SUFFIX_RE, '')
return base === key || !isNamed(base)
})
}
function printKeys(label, keys) {
if (keys.length === 0) {
return
}
console.error(`${label}:`)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
function printFallbackDrift(keys, extracted, english) {
console.error(
'Inline defaults differ from en.json. The catalog value is what renders, so editing a fallback alone changes nothing.\n' +
'Set the en.json value to the new copy and update every locale catalog to match, or move the new copy to a new key.'
)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
console.error(` en.json: ${JSON.stringify(english.get(key))}`)
console.error(` source: ${JSON.stringify(extracted.get(key))}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
async function extractToTemporaryCatalog(root, tempDir) {
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
const outputPattern = path.join(tempDir, '{{language}}.json')
// Treat extraction as check evidence, not another committed catalog feature authors must synchronize.
const [{ runProcessSync }, { describeProcessFailure }, { resolvePinnedBunExecutable }] =
await Promise.all([
import('@orca/process-host'),
import('./process-failure-message.mjs'),
import('./pinned-bun-executable.mjs')
])
// Missing or unusable optional Bun keeps the existing parent runtime.
const program = resolvePinnedBunExecutable(root, runProcessSync) ?? process.execPath
const result = runProcessSync({
program,
args: [cliPath, '--config', 'config/i18next.config.ts', 'extract', '--sync-primary', '--quiet'],
cwd: root,
env: {
...process.env,
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
},
timeoutMs: null,
maxOutputBytes: 1024 * 1024
})
if (result.code !== 0 || result.signal || result.timedOut || result.outputTruncated) {
throw new Error(`Localization extraction failed: ${describeProcessFailure(result)}`)
}
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
}
export async function main(root = process.cwd()) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
try {
const [extractedCatalog, englishCatalog, keyEvidence] = await Promise.all([
extractToTemporaryCatalog(root, tempDir),
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse),
collectSourceKeyEvidence(root)
])
const result = compareExtraction(extractedCatalog, englishCatalog)
const unreferenced = findUnreferencedKeys(result.orphans, result.extracted, keyEvidence)
console.log(
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced (${unreferenced.length} named nowhere in source), and ${result.fallbackDrift.length} inline defaults differ from en.json.`
)
let failed = false
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
failed = true
}
if (result.fallbackDrift.length > 0) {
printFallbackDrift(result.fallbackDrift, result.extracted, result.english)
failed = true
}
if (unreferenced.length > 0) {
printKeys(
'Catalog keys no source references. Delete them from every locale catalog and config/scripts/locale-*-overrides.*, then run `pnpm sync:localization-runtime-catalog`',
unreferenced
)
failed = true
}
return failed ? 1 : 0
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}