Files
orca/config/scripts/verify-mobile-localization-catalog.mjs
T

637 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import {
collectMobileTranslationBindings,
isMobileTranslationCall,
mobileTranslationCallPrefix
} from './mobile-localization-translation-bindings.mjs'
import {
EXACT_LANGUAGE_NEUTRAL_KEYS,
PRODUCT_GLOSSARY,
REVIEWED_KEY_TRANSLATIONS,
REQUIRED_TARGET_TRANSLATIONS
} from './mobile-localization-product-glossary.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SOURCE_RELATIVE_ROOTS = [path.join('mobile', 'app'), path.join('mobile', 'src')]
const LOCALES_RELATIVE_DIR = path.join('mobile', 'src', 'i18n', 'locales')
const MOBILE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh']
const NATIVE_LOCALES = ['en', 'es', 'ja', 'ko', 'zh-Hans']
const NATIVE_LOCALES_RELATIVE_DIR = path.join('mobile', 'locales')
const NATIVE_REQUIRED_KEYS = [
'ios.CFBundleDisplayName',
'ios.NSCameraUsageDescription',
'ios.NSLocalNetworkUsageDescription',
'ios.NSMicrophoneUsageDescription',
'ios.NSPhotoLibraryUsageDescription',
'android.app_name'
]
const NATIVE_LOCALE_PATHS = new Map(
NATIVE_LOCALES.map((locale) => [locale, `./locales/${locale}.json`])
)
const RUNTIME_TO_NATIVE_LOCALE = new Map([
['en', 'en'],
['es', 'es'],
['ja', 'ja'],
['ko', 'ko'],
['zh', 'zh-Hans']
])
const PROTECTED_LITERALS = [
'sudo ufw allow 6768',
'npm run dev',
'pnpm build',
'stablyai/orca',
'onOrca.dev',
'orca.yaml',
'Tailscale',
'GitHub',
'GitLab',
'Codex',
'Claude',
'Orca',
'WSL',
'SSH',
'HEAD',
'MR !',
'HOOKS'
]
const PLACEHOLDER_RE = /\{\{\s*([^,}\s]+)(?:,[^}]*)?\}\}/g
const ENCODED_HTML_ENTITY_RE = /&(?:amp|apos|gt|lt|quot);/i
const INTENT_MESSAGE_ID_RE = /^[a-z][A-Za-z0-9]*(?:\.[a-z][A-Za-z0-9]*)+$/
const POSITIONAL_PLACEHOLDER_RE = /^value\d+$/
const LANGUAGE_NEUTRAL_VALUES = new Set([
'@orca_build',
'[x]',
'Aider',
'Amp',
'Ante',
'Antigravity',
'Auggie',
'Autohand Code',
'Charm',
'Claude',
'Claude Agent Teams',
'Cline',
'Codebuff',
'Codex',
'Command Code',
'Continue',
'Cursor',
'Devin',
'Droid',
'Gemini',
'GitHub',
'GitHub Copilot',
'GitHub Releases',
'GitLab',
'Goose',
'Grok',
'HEAD',
'Hermes',
'Kilocode',
'Kimi',
'Kiro',
'Linear',
'Markdown',
'MiMo Code',
'Mistral Vibe',
'MR',
'MR !',
'OMP',
'onOrca.dev',
'OpenAI API',
'OpenClaude',
'OpenClaw',
'OpenCode',
'Orca',
'Orca Relay',
'orca.yaml',
'ORCA.YAML',
'Pi',
'PR',
'Qwen Code',
'Rovo Dev',
'stablyai/orca',
'Trae',
'YYYY-MM-DD'
])
const EXACT_LANGUAGE_NEUTRAL_VALUES = new Set(['[x]', 'OpenAI API'])
function normalizePath(root, filePath) {
return path.relative(root, filePath).split(path.sep).join('/')
}
function isSourceFile(filePath) {
const fileName = path.basename(filePath)
return (
SOURCE_EXTENSIONS.has(path.extname(fileName)) &&
!fileName.includes('.test.') &&
!fileName.includes('.spec.')
)
}
async function collectSourceFiles(directory) {
const entries = await fs.readdir(directory, { withFileTypes: true })
const files = []
for (const entry of entries) {
const filePath = path.join(directory, entry.name)
if (entry.isDirectory()) {
files.push(...(await collectSourceFiles(filePath)))
} else if (entry.isFile() && isSourceFile(filePath)) {
files.push(filePath)
}
}
return files
}
function flattenCatalog(value, catalogName, prefix = '', entries = new Map(), issues = []) {
if (typeof value === 'string') {
entries.set(prefix, value)
if (value.trim().length === 0) {
issues.push(`${catalogName}: ${prefix} must not be empty`)
}
if (ENCODED_HTML_ENTITY_RE.test(value)) {
issues.push(`${catalogName}: ${prefix} contains an encoded HTML entity`)
}
return { entries, issues }
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
issues.push(`${catalogName}: ${prefix || '<root>'} must be a string or object`)
return { entries, issues }
}
for (const [key, child] of Object.entries(value)) {
flattenCatalog(child, catalogName, prefix ? `${prefix}.${key}` : key, entries, issues)
}
return { entries, issues }
}
function locationFor(root, filePath, sourceFile, node) {
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
return `${normalizePath(root, filePath)}:${position.line + 1}:${position.character + 1}`
}
function unwrapExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return unwrapExpression(node.expression)
}
return node
}
function collectKeyBranches(node) {
const expression = unwrapExpression(node)
if (ts.isStringLiteralLike(expression)) {
return { keys: [expression.text], inspected: true }
}
if (ts.isConditionalExpression(expression)) {
const whenTrue = collectKeyBranches(expression.whenTrue)
const whenFalse = collectKeyBranches(expression.whenFalse)
return {
keys: [...whenTrue.keys, ...whenFalse.keys],
inspected: whenTrue.inspected && whenFalse.inspected
}
}
return { keys: [], inspected: false }
}
function propertyNameText(name) {
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
return name.text
}
return undefined
}
function collectOptionNames(node) {
if (!node) {
return { inspected: true, names: [] }
}
const expression = unwrapExpression(node)
if (!ts.isObjectLiteralExpression(expression)) {
return { inspected: false, names: [] }
}
const names = []
for (const property of expression.properties) {
if (ts.isShorthandPropertyAssignment(property)) {
names.push(property.name.text)
continue
}
if (ts.isPropertyAssignment(property)) {
const name = propertyNameText(property.name)
if (name !== undefined) {
names.push(name)
continue
}
}
return { inspected: false, names: [] }
}
return { inspected: true, names: [...new Set(names)].sort() }
}
function collectPlaceholderNames(value) {
const names = []
for (const match of value.matchAll(PLACEHOLDER_RE)) {
names.push(match[1])
}
return [...new Set(names)].sort()
}
function sameValues(left, right) {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function occurrenceCount(value, literal) {
return value.split(literal).length - 1
}
function verifyProtectedLiterals(englishValue, localeValue, catalogName, key) {
const issues = []
for (const literal of PROTECTED_LITERALS) {
if (occurrenceCount(localeValue, literal) < occurrenceCount(englishValue, literal)) {
issues.push(`${catalogName} protected literal mismatch: ${key} must preserve ${literal}`)
}
}
return issues
}
function isLanguageNeutralValue(value, locale) {
const withoutPlaceholders = value.replace(PLACEHOLDER_RE, '').trim()
return (
LANGUAGE_NEUTRAL_VALUES.has(value) ||
/^(?:Alt|Ctrl|Shift)(?:\+(?:[A-Z]|Tab))?$/.test(value) ||
/^(?:Del|Enter|Esc|Ins|PgDn|PgUp|Tab)$/.test(value) ||
/^(?:F|H)\d{1,2}$/.test(value) ||
/^(?:https?:\/\/|orca:\/\/|lin_api_)\S+$/.test(value) ||
/^(?:npm run dev|Linux: sudo ufw allow 6768|src\/renderer packages\/ui)$/.test(value) ||
(locale === 'es' && /^(?:commit|commits)$/.test(withoutPlaceholders)) ||
withoutPlaceholders === '' ||
withoutPlaceholders === '..HEAD' ||
withoutPlaceholders === '×'
)
}
function verifyTerminology(englishValue, localeValue, locale, catalogName, key) {
const issues = []
if (
locale === 'es' &&
/\bbranch(?:es)?\b/i.test(englishValue) &&
/\bsucursal(?:es)?\b/i.test(localeValue)
) {
issues.push(`${catalogName} Git terminology mismatch: ${key} translates branch as sucursal`)
}
if (locale === 'zh' && /\bhost\b/i.test(englishValue) && localeValue.includes('主人')) {
issues.push(`${catalogName} host terminology mismatch: ${key} uses 主人`)
}
if (locale === 'zh' && /\bagent/i.test(englishValue) && localeValue.includes('检测剂')) {
issues.push(`${catalogName} agent terminology mismatch: ${key} uses 检测剂`)
}
const expectedValue =
REVIEWED_KEY_TRANSLATIONS.get(key)?.[locale] ?? PRODUCT_GLOSSARY.get(englishValue)?.[locale]
if (expectedValue && localeValue !== expectedValue) {
issues.push(`${catalogName} product terminology mismatch: ${key} must use ${expectedValue}`)
}
return issues
}
export function collectMobileTranslationCalls(filePath, sourceText, root = process.cwd()) {
const sourceKind =
filePath.endsWith('.tsx') || filePath.endsWith('.jsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sourceFile = ts.createSourceFile(
filePath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceKind
)
const calls = []
const bindings = collectMobileTranslationBindings(sourceFile)
function visit(node) {
if (ts.isCallExpression(node) && isMobileTranslationCall(node, sourceFile, bindings)) {
const keyExpression = node.arguments[0]
if (keyExpression) {
const prefix = mobileTranslationCallPrefix(node, sourceFile, bindings)
const branches = collectKeyBranches(keyExpression)
calls.push({
...branches,
keys: branches.keys.map((key) => (prefix ? `${prefix}.${key}` : key)),
location: locationFor(root, filePath, sourceFile, keyExpression),
options: collectOptionNames(node.arguments[1]),
source: keyExpression.getText(sourceFile)
})
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return calls
}
function verifyCalls(calls, englishEntries) {
const issues = []
for (const call of calls) {
if (!call.inspected) {
issues.push(
`${call.location} translation key expression is not statically inspectable: ${call.source}`
)
continue
}
if (!call.options.inspected) {
issues.push(`${call.location} translation options are not statically inspectable`)
continue
}
for (const key of call.keys) {
const englishValue = englishEntries.get(key)
if (englishValue === undefined) {
issues.push(`${call.location} missing English key: ${key}`)
continue
}
const placeholders = collectPlaceholderNames(englishValue)
if (!sameValues(call.options.names, placeholders)) {
issues.push(
`${call.location} ${key} options [${call.options.names.join(', ')}] do not match placeholders [${placeholders.join(', ')}]`
)
}
}
}
return issues
}
function verifyEnglishEntries(calls, englishEntries) {
const issues = []
const referencedKeys = new Set(calls.flatMap((call) => (call.inspected ? call.keys : [])))
for (const [key, value] of englishEntries) {
if (!INTENT_MESSAGE_ID_RE.test(key) || key.startsWith('m.')) {
issues.push(`en.json message ID must be intent-named: ${key}`)
}
for (const placeholder of collectPlaceholderNames(value)) {
if (POSITIONAL_PLACEHOLDER_RE.test(placeholder)) {
issues.push(`en.json placeholder must be intent-named: ${key} uses ${placeholder}`)
}
}
if (!referencedKeys.has(key)) {
issues.push(`en.json has orphaned key: ${key}`)
}
}
return issues
}
function verifyLocaleEntries(
englishEntries,
locale,
localeEntries,
catalogName = `${locale}.json`,
requiredTranslations = REQUIRED_TARGET_TRANSLATIONS
) {
const issues = []
const localeKeys = [...localeEntries.keys()].sort()
for (const key of requiredTranslations) {
if (englishEntries.has(key) && !localeEntries.has(key)) {
issues.push(`${catalogName} missing required translation: ${key}`)
}
}
for (const key of localeKeys) {
const englishValue = englishEntries.get(key)
if (englishValue === undefined) {
issues.push(`${catalogName} has extra key: ${key}`)
continue
}
const englishPlaceholders = collectPlaceholderNames(englishValue)
const localeValue = localeEntries.get(key)
const localePlaceholders = collectPlaceholderNames(localeValue)
if (!sameValues(englishPlaceholders, localePlaceholders)) {
issues.push(`${catalogName} placeholder mismatch: ${key}`)
}
if (
(EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) || EXACT_LANGUAGE_NEUTRAL_KEYS.has(key)) &&
localeValue !== englishValue
) {
issues.push(`${catalogName} must preserve language-neutral value: ${key}`)
}
issues.push(...verifyProtectedLiterals(englishValue, localeValue, catalogName, key))
issues.push(...verifyTerminology(englishValue, localeValue, locale, catalogName, key))
if (localeValue === englishValue && !isLanguageNeutralValue(englishValue, locale)) {
issues.push(`${catalogName} copies English instead of using fallback: ${key}`)
}
}
return issues
}
function findPluginOptions(expoConfig, pluginName) {
const plugin = expoConfig.plugins?.find(
(entry) => Array.isArray(entry) && entry[0] === pluginName
)
return Array.isArray(plugin) && typeof plugin[1] === 'object' && plugin[1] !== null
? plugin[1]
: {}
}
function verifyNativeFallbacks(expoConfig, englishEntries) {
const camera = findPluginOptions(expoConfig, 'expo-camera')
const imagePicker = findPluginOptions(expoConfig, 'expo-image-picker')
const infoPlist = expoConfig.ios?.infoPlist ?? {}
const fallbackValues = new Map([
['ios.CFBundleDisplayName', [expoConfig.name]],
['ios.NSCameraUsageDescription', [camera.cameraPermission]],
['ios.NSLocalNetworkUsageDescription', [infoPlist.NSLocalNetworkUsageDescription]],
[
'ios.NSMicrophoneUsageDescription',
[infoPlist.NSMicrophoneUsageDescription, camera.microphonePermission]
],
[
'ios.NSPhotoLibraryUsageDescription',
[infoPlist.NSPhotoLibraryUsageDescription, imagePicker.photosPermission]
],
['android.app_name', [expoConfig.name]]
])
const issues = []
for (const [key, values] of fallbackValues) {
const englishValue = englishEntries.get(key)
for (const value of values) {
if (typeof value !== 'string' || value !== englishValue) {
issues.push(`mobile/app.json English native fallback mismatch: ${key}`)
break
}
}
}
return issues
}
async function verifyNativeCatalogs(root) {
const issues = []
const appConfigPath = path.join(root, 'mobile', 'app.json')
let appConfig
try {
appConfig = JSON.parse(await fs.readFile(appConfigPath, 'utf8'))
} catch (error) {
return [
`${normalizePath(root, appConfigPath)} could not be read: ${error instanceof Error ? error.message : String(error)}`
]
}
const expoConfig = appConfig.expo ?? {}
const configuredLocales = expoConfig.locales ?? {}
for (const [locale, expectedPath] of NATIVE_LOCALE_PATHS) {
if (configuredLocales[locale] !== expectedPath) {
issues.push(`mobile/app.json locale ${locale} must map to ${expectedPath}`)
}
}
for (const locale of Object.keys(configuredLocales)) {
if (!NATIVE_LOCALE_PATHS.has(locale)) {
issues.push(`mobile/app.json has unsupported native locale mapping: ${locale}`)
}
}
const localizationPlugin = findPluginOptions(expoConfig, 'expo-localization')
if (!sameValues(localizationPlugin.supportedLocales ?? [], NATIVE_LOCALES)) {
issues.push('mobile/app.json expo-localization supportedLocales mismatch')
}
for (const [runtimeLocale, nativeLocale] of RUNTIME_TO_NATIVE_LOCALE) {
if (!MOBILE_LOCALES.includes(runtimeLocale) || !NATIVE_LOCALE_PATHS.has(nativeLocale)) {
issues.push(`mobile locale mapping is incomplete: ${runtimeLocale} -> ${nativeLocale}`)
}
}
const nativeCatalogs = new Map()
for (const locale of NATIVE_LOCALES) {
const catalogPath = path.join(root, NATIVE_LOCALES_RELATIVE_DIR, `${locale}.json`)
try {
const flattened = flattenCatalog(
JSON.parse(await fs.readFile(catalogPath, 'utf8')),
`mobile/locales/${locale}.json`
)
nativeCatalogs.set(locale, flattened.entries)
issues.push(...flattened.issues)
} catch (error) {
issues.push(
`${normalizePath(root, catalogPath)} could not be read: ${error instanceof Error ? error.message : String(error)}`
)
}
}
for (const [locale, entries] of nativeCatalogs) {
for (const key of NATIVE_REQUIRED_KEYS) {
if (!entries.has(key)) {
issues.push(`mobile/locales/${locale}.json missing required native key: ${key}`)
}
}
for (const key of entries.keys()) {
if (!NATIVE_REQUIRED_KEYS.includes(key)) {
issues.push(`mobile/locales/${locale}.json has unsupported native key: ${key}`)
}
}
}
const englishEntries = nativeCatalogs.get('en')
if (englishEntries) {
issues.push(...verifyNativeFallbacks(expoConfig, englishEntries))
for (const locale of NATIVE_LOCALES.slice(1)) {
const localeEntries = nativeCatalogs.get(locale)
if (localeEntries) {
const runtimeLocale = locale === 'zh-Hans' ? 'zh' : locale
issues.push(
...verifyLocaleEntries(
englishEntries,
runtimeLocale,
localeEntries,
`mobile/locales/${locale}.json`,
[]
)
)
}
}
}
return issues
}
async function readCatalogs(root) {
const localeDirectory = path.join(root, LOCALES_RELATIVE_DIR)
const catalogs = new Map()
const issues = []
for (const locale of MOBILE_LOCALES) {
const catalogPath = path.join(localeDirectory, `${locale}.json`)
try {
const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8'))
const flattened = flattenCatalog(catalog, `${locale}.json`)
catalogs.set(locale, flattened.entries)
issues.push(...flattened.issues)
} catch (error) {
issues.push(
`${normalizePath(root, catalogPath)} could not be read: ${error instanceof Error ? error.message : String(error)}`
)
}
}
return { catalogs, issues }
}
function reportIssues(issues) {
console.error(`Mobile localization catalog verification failed with ${issues.length} issue(s).`)
for (const issue of issues.slice(0, 100)) {
console.error(issue)
}
if (issues.length > 100) {
console.error(`...and ${issues.length - 100} more`)
}
}
export async function main(root = process.cwd()) {
const { catalogs, issues } = await readCatalogs(root)
issues.push(...(await verifyNativeCatalogs(root)))
const englishEntries = catalogs.get('en')
if (!englishEntries) {
reportIssues(issues)
return 1
}
const calls = []
for (const relativeRoot of SOURCE_RELATIVE_ROOTS) {
const sourceRoot = path.join(root, relativeRoot)
for (const filePath of await collectSourceFiles(sourceRoot)) {
calls.push(
...collectMobileTranslationCalls(filePath, await fs.readFile(filePath, 'utf8'), root)
)
}
}
issues.push(...verifyCalls(calls, englishEntries))
issues.push(...verifyEnglishEntries(calls, englishEntries))
for (const locale of MOBILE_LOCALES.slice(1)) {
const localeEntries = catalogs.get(locale)
if (localeEntries) {
issues.push(...verifyLocaleEntries(englishEntries, locale, localeEntries))
}
}
if (issues.length > 0) {
reportIssues(issues)
return 1
}
console.log(
`Verified ${calls.length} mobile translation calls and ${englishEntries.size} catalog keys across ${MOBILE_LOCALES.length} locales.`
)
return 0
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}