fix(mobile): close localization audit bypasses

This commit is contained in:
Brennan Benson
2026-08-01 08:53:18 -07:00
parent 89f6f51f21
commit b5d04b3b24
14 changed files with 660 additions and 185 deletions
+17 -8
View File
@@ -193,15 +193,24 @@ function isInsideLocalizationCall(node, sourceFile, mobileBindings) {
function isUnboundRenderedTranslationArgument(node, sourceFile, mobileBindings) {
const call = findAncestor(node, ts.isCallExpression)
if (!call || !ts.isIdentifier(call.expression)) {
if (!call) {
return false
}
const name = call.expression.text
const resemblesTranslator =
LOCALIZATION_CALL_NAMES.has(name) ||
mobileBindings.translatorNames.has(name) ||
mobileBindings.fixedTranslatorNames.has(name) ||
mobileBindings.prefixedTranslatorNames.has(name)
let resemblesTranslator = false
if (ts.isIdentifier(call.expression)) {
const name = call.expression.text
resemblesTranslator =
LOCALIZATION_CALL_NAMES.has(name) ||
mobileBindings.translatorNames.has(name) ||
mobileBindings.fixedTranslatorNames.has(name) ||
mobileBindings.prefixedTranslatorNames.has(name)
} else if (ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === 't') {
const rootName = expressionNameText(call.expression.expression)?.split('.')[0]
resemblesTranslator = Boolean(
rootName &&
(mobileBindings.namespaceNames.has(rootName) || mobileBindings.instanceNames.has(rootName))
)
}
return (
resemblesTranslator &&
!isMobileTranslationCall(call, sourceFile, mobileBindings) &&
@@ -437,7 +446,7 @@ export function collectLocalizationCandidates(filePath, sourceText, root = proce
}
const kind = mobileSource
? (classifyMobileStringNode(node, userVisibleErrorSource) ??
? (classifyMobileStringNode(node, userVisibleErrorSource, mobileBindings) ??
(isUnboundRenderedTranslationArgument(node, sourceFile, mobileBindings)
? 'unbound-localization-call'
: undefined))
@@ -388,20 +388,57 @@ function isUserVisibleErrorArgument(node, userVisibleErrorSource) {
)
}
function isNotificationChannelName(node) {
function resolveObjectLiteral(node, bindings, seen = new Set()) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return resolveObjectLiteral(node.expression, bindings, seen)
}
if (ts.isObjectLiteralExpression(node)) {
return node
}
if (!bindings || !ts.isIdentifier(node)) {
return undefined
}
const binding = bindings.resolveBinding(node)
if (!binding || seen.has(binding) || !ts.isVariableDeclaration(binding) || !binding.initializer) {
return undefined
}
seen.add(binding)
return resolveObjectLiteral(binding.initializer, bindings, seen)
}
function isNotificationChannelName(node, bindings) {
const property = findAncestor(node, ts.isPropertyAssignment)
const object = property?.parent
const call = object?.parent
return Boolean(
property &&
propertyNameText(property.name) === 'name' &&
object &&
ts.isObjectLiteralExpression(object) &&
call &&
ts.isCallExpression(call) &&
call.arguments[1] === object &&
expressionNameText(call.expression)?.endsWith('setNotificationChannelAsync')
)
if (
!property ||
propertyNameText(property.name) !== 'name' ||
!object ||
!ts.isObjectLiteralExpression(object)
) {
return false
}
let found = false
function visit(current) {
if (found) {
return
}
if (
ts.isCallExpression(current) &&
current.arguments[1] &&
expressionNameText(current.expression)?.endsWith('setNotificationChannelAsync') &&
resolveObjectLiteral(current.arguments[1], bindings) === object
) {
found = true
return
}
ts.forEachChild(current, visit)
}
visit(node.getSourceFile())
return found
}
function hasDisqualifyingBinaryAncestor(node) {
@@ -448,7 +485,7 @@ function isComparisonOperand(node) {
)
}
export function classifyMobileStringNode(node, userVisibleErrorSource) {
export function classifyMobileStringNode(node, userVisibleErrorSource, bindings) {
if (hasAncestorObjectPropertyName(node, new Set(['className', 'classNames']))) {
return undefined
}
@@ -471,7 +508,7 @@ export function classifyMobileStringNode(node, userVisibleErrorSource) {
if (isComparisonOperand(node)) {
return undefined
}
if (isNotificationChannelName(node)) {
if (isNotificationChannelName(node, bindings)) {
return 'notification-channel-name'
}
@@ -178,7 +178,8 @@ function projectRowType() {
return 'issue'
}
export function Example() {
return <>{projectRowType() ? <Text>{lookup()}</Text> : null}</>
const render = lookup
return <>{projectRowType() ? <Text>{render()}</Text> : null}</>
}
`
const candidates = collectLocalizationCandidates(
@@ -192,10 +193,11 @@ export function Example() {
it('finds Android notification-channel names', () => {
const source = `
Notifications.setNotificationChannelAsync('orca-desktop', {
const channel = {
name: 'Desktop Notifications',
importance: Notifications.AndroidImportance.HIGH
})
}
Notifications.setNotificationChannelAsync('orca-desktop', channel)
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/notifications/local-notification-scheduling.ts',
@@ -224,10 +226,14 @@ export function toRow(item) {
it('recognizes aliased translators and reports calls shadowed by parameters', () => {
const source = `
import { t as translateMobile } from '@/i18n/mobile-i18n'
import * as i18n from '@/i18n/mobile-i18n'
const localized = translateMobile('example.localized')
export function Example(translateMobile) {
return <Text>{translateMobile('Unlocalized shadowed copy')}</Text>
}
export function MemberExample(i18n) {
return <Text>{i18n.t('Unlocalized shadowed member copy')}</Text>
}
`
const candidates = collectLocalizationCandidates(
'/repo/mobile/src/components/Example.tsx',
@@ -235,7 +241,10 @@ export function Example(translateMobile) {
'/repo'
)
expect(candidates.map((candidate) => candidate.text)).toEqual(['Unlocalized shadowed copy'])
expect(candidates.map((candidate) => candidate.text)).toEqual([
'Unlocalized shadowed copy',
'Unlocalized shadowed member copy'
])
})
it('rejects stale allowlist entries before their approval can be reused', async () => {
@@ -0,0 +1,44 @@
export const REQUIRED_TARGET_TRANSLATIONS = new Set([
'pullRequest.checks.running',
'pullRequest.unresolvedComments.one',
'pullRequest.unresolvedComments.other'
])
export const PRODUCT_GLOSSARY = new Map([
[
'Sparse Checkout',
{
es: 'Checkout disperso',
ja: 'スパースチェックアウト',
ko: '스파스 체크아웃',
zh: '稀疏检出'
}
],
[
'Detecting Agents',
{
es: 'Detectando agentes',
ja: 'エージェントを検出中',
ko: '에이전트 감지 중',
zh: '正在检测智能体'
}
],
[
'Loading terminal',
{
es: 'Cargando terminal',
ja: 'ターミナルを読み込み中',
ko: '터미널 불러오는 중',
zh: '正在加载终端'
}
],
[
'Open source control',
{
es: 'Abrir control de código fuente',
ja: 'ソース管理を開く',
ko: '소스 제어 열기',
zh: '打开源代码管理'
}
]
])
@@ -1,6 +1,10 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { collectSourceBindings } from './mobile-localization-source-bindings.mjs'
const SOURCE_BINDINGS = new WeakMap()
function isDirectDisplayExpressionParent(parent, child) {
if (
ts.isParenthesizedExpression(parent) ||
@@ -104,7 +108,7 @@ function functionName(node) {
: undefined
}
export function directReturnFunctionName(node) {
function directReturnFunction(node) {
let current = node
while (current.parent && isDirectDisplayExpressionParent(current.parent, current)) {
current = current.parent
@@ -115,18 +119,102 @@ export function directReturnFunctionName(node) {
while (owner && !ts.isFunctionLike(owner)) {
owner = owner.parent
}
return owner ? functionName(owner) : undefined
return owner
}
return parent && ts.isArrowFunction(parent) && parent.body === current
? functionName(parent)
: undefined
return parent && ts.isArrowFunction(parent) && parent.body === current ? parent : undefined
}
export function directReturnFunctionName(node) {
const owner = directReturnFunction(node)
return owner ? functionName(owner) : undefined
}
function bindingsFor(sourceFile) {
const cached = SOURCE_BINDINGS.get(sourceFile)
if (cached) {
return cached
}
const bindings = collectSourceBindings(sourceFile)
SOURCE_BINDINGS.set(sourceFile, bindings)
return bindings
}
function unwrapAliasExpression(node) {
if (
ts.isParenthesizedExpression(node) ||
ts.isAsExpression(node) ||
ts.isSatisfiesExpression(node)
) {
return unwrapAliasExpression(node.expression)
}
return node
}
function renderedFunctionBindings(owner, sourceFile, bindings) {
const targets = new Set()
if (owner.name && ts.isIdentifier(owner.name)) {
const binding = bindings.resolveBinding(owner.name)
if (binding) {
targets.add(binding)
}
}
const declaration = owner.parent
if (
(ts.isArrowFunction(owner) || ts.isFunctionExpression(owner)) &&
ts.isVariableDeclaration(declaration) &&
ts.isIdentifier(declaration.name)
) {
const binding = bindings.resolveBinding(declaration.name)
if (binding) {
targets.add(binding)
}
}
let changed = true
while (changed) {
changed = false
function visit(node) {
let alias
let initializer
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
alias = node.name
initializer = unwrapAliasExpression(node.initializer)
} else if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isIdentifier(node.left)
) {
alias = node.left
initializer = unwrapAliasExpression(node.right)
}
if (alias && initializer && ts.isIdentifier(initializer)) {
const sourceBinding = bindings.resolveBinding(initializer)
const aliasBinding = bindings.resolveBinding(alias)
if (
sourceBinding &&
aliasBinding &&
targets.has(sourceBinding) &&
!targets.has(aliasBinding)
) {
targets.add(aliasBinding)
changed = true
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
}
return targets
}
export function isReturnedByRenderedFunction(node, isRenderedExpression = isRenderedJsxExpression) {
const name = directReturnFunctionName(node)
if (!name) {
const owner = directReturnFunction(node)
if (!owner) {
return false
}
const sourceFile = node.getSourceFile()
const bindings = bindingsFor(sourceFile)
const targetBindings = renderedFunctionBindings(owner, sourceFile, bindings)
let rendered = false
function visit(current) {
if (rendered) {
@@ -135,7 +223,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
if (
ts.isCallExpression(current) &&
ts.isIdentifier(current.expression) &&
current.expression.text === name &&
targetBindings.has(bindings.resolveBinding(current.expression)) &&
isRenderedExpression(current)
) {
rendered = true
@@ -143,7 +231,7 @@ export function isReturnedByRenderedFunction(node, isRenderedExpression = isRend
}
ts.forEachChild(current, visit)
}
visit(node.getSourceFile())
visit(sourceFile)
return rendered
}
@@ -0,0 +1,122 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
function bindingDeclarations(name, declaration, entries = []) {
if (!name) {
return entries
}
if (ts.isIdentifier(name)) {
entries.push([name.text, declaration])
} else {
for (const element of name.elements) {
bindingDeclarations(element.name, element, entries)
}
}
return entries
}
function isLexicalScope(node) {
return (
ts.isSourceFile(node) ||
ts.isFunctionLike(node) ||
ts.isBlock(node) ||
ts.isCaseBlock(node) ||
ts.isCatchClause(node) ||
ts.isForStatement(node) ||
ts.isForInStatement(node) ||
ts.isForOfStatement(node)
)
}
function nearestScope(node, sourceFile, predicate = isLexicalScope) {
let current = node.parent
while (current && current !== sourceFile) {
if (predicate(current)) {
return current
}
current = current.parent
}
return sourceFile
}
function functionScope(node, sourceFile) {
return nearestScope(node, sourceFile, ts.isFunctionLike)
}
function collectDeclarations(sourceFile) {
const declarations = new Map()
function add(scope, name, declaration) {
const byName = declarations.get(scope) ?? new Map()
const matches = byName.get(name) ?? []
matches.push(declaration)
byName.set(name, matches)
declarations.set(scope, byName)
}
function visit(node) {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause
if (clause?.name) {
add(sourceFile, clause.name.text, clause.name)
}
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
add(sourceFile, clause.namedBindings.name.text, clause.namedBindings)
}
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements) {
add(sourceFile, element.name.text, element)
}
}
} else if (ts.isParameter(node)) {
for (const [name, declaration] of bindingDeclarations(node.name, node)) {
add(node.parent, name, declaration)
}
} else if (ts.isVariableDeclaration(node)) {
const declarationList = node.parent
const scope =
ts.isVariableDeclarationList(declarationList) &&
(declarationList.flags & ts.NodeFlags.BlockScoped) === 0
? functionScope(node, sourceFile)
: nearestScope(node, sourceFile)
for (const [name, declaration] of bindingDeclarations(node.name, node)) {
add(scope, name, declaration)
}
} else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
add(nearestScope(node, sourceFile), node.name.text, node)
} else if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) {
add(node, node.name.text, node)
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
for (const [name, declaration] of bindingDeclarations(
node.variableDeclaration.name,
node.variableDeclaration
)) {
add(node, name, declaration)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return declarations
}
export function collectSourceBindings(sourceFile) {
const declarations = collectDeclarations(sourceFile)
return {
resolveBinding(identifier) {
let current = identifier.parent
while (current) {
const matches = declarations.get(current)?.get(identifier.text)
if (matches?.length) {
return matches[0]
}
if (current === sourceFile) {
break
}
current = current.parent
}
return undefined
}
}
}
@@ -1,130 +1,23 @@
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { collectSourceBindings } from './mobile-localization-source-bindings.mjs'
const MOBILE_I18N_MODULE_RE = /(?:^|\/)mobile-i18n$/
function bindingNames(name, names = []) {
if (!name) {
return names
}
if (ts.isIdentifier(name)) {
names.push(name.text)
} else {
for (const element of name.elements) {
bindingNames(element.name, names)
}
}
return names
}
function isLexicalScope(node) {
return (
ts.isSourceFile(node) ||
ts.isFunctionLike(node) ||
ts.isBlock(node) ||
ts.isCaseBlock(node) ||
ts.isCatchClause(node) ||
ts.isForStatement(node) ||
ts.isForInStatement(node) ||
ts.isForOfStatement(node)
)
}
function nearestScope(node, sourceFile, predicate = isLexicalScope) {
let current = node.parent
while (current && current !== sourceFile) {
if (predicate(current)) {
return current
}
current = current.parent
}
return sourceFile
}
function functionScope(node, sourceFile) {
return nearestScope(node, sourceFile, ts.isFunctionLike)
}
function collectDeclarations(sourceFile) {
const declarations = new Map()
function add(scope, name, declaration) {
const byName = declarations.get(scope) ?? new Map()
const matches = byName.get(name) ?? []
matches.push(declaration)
byName.set(name, matches)
declarations.set(scope, byName)
}
function visit(node) {
if (ts.isImportDeclaration(node)) {
const clause = node.importClause
if (clause?.name) {
add(sourceFile, clause.name.text, clause.name)
}
if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings)) {
add(sourceFile, clause.namedBindings.name.text, clause.namedBindings)
}
if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings)) {
for (const element of clause.namedBindings.elements) {
add(sourceFile, element.name.text, element)
}
}
} else if (ts.isParameter(node)) {
for (const name of bindingNames(node.name)) {
add(node.parent, name, node)
}
} else if (ts.isVariableDeclaration(node)) {
const declarationList = node.parent
const scope =
ts.isVariableDeclarationList(declarationList) &&
(declarationList.flags & ts.NodeFlags.BlockScoped) === 0
? functionScope(node, sourceFile)
: nearestScope(node, sourceFile)
for (const name of bindingNames(node.name)) {
add(scope, name, node)
}
} else if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) {
add(nearestScope(node, sourceFile), node.name.text, node)
} else if ((ts.isFunctionExpression(node) || ts.isClassExpression(node)) && node.name) {
add(node, node.name.text, node)
} else if (ts.isCatchClause(node) && node.variableDeclaration) {
for (const name of bindingNames(node.variableDeclaration.name)) {
add(node, name, node.variableDeclaration)
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return declarations
}
function resolveBinding(identifier, sourceFile, declarations) {
let current = identifier.parent
while (current) {
const matches = declarations.get(current)?.get(identifier.text)
if (matches?.length) {
return matches[0]
}
if (current === sourceFile) {
break
}
current = current.parent
}
return undefined
}
function resolvesTo(identifier, bindings) {
return bindings.resolveBinding(identifier)
}
function isMobileI18nNamespace(node, bindings) {
return ts.isIdentifier(node) && bindings.namespaceBindings.has(resolvesTo(node, bindings))
}
function isNamespaceMember(node, memberName, bindings) {
return (
ts.isPropertyAccessExpression(node) &&
node.name.text === memberName &&
ts.isIdentifier(node.expression) &&
bindings.namespaceBindings.has(resolvesTo(node.expression, bindings))
isMobileI18nNamespace(node.expression, bindings)
)
}
@@ -135,12 +28,21 @@ function isMobileI18nInstance(node, bindings) {
return isNamespaceMember(node, 'mobileI18n', bindings)
}
function isFixedTranslatorFactory(node, bindings) {
if (ts.isIdentifier(node)) {
return bindings.fixedFactoryBindings.has(resolvesTo(node, bindings))
}
return (
ts.isPropertyAccessExpression(node) &&
node.name.text === 'getFixedT' &&
isMobileI18nInstance(node.expression, bindings)
)
}
function isEnglishFixedTranslator(node, bindings) {
return (
ts.isCallExpression(node) &&
ts.isPropertyAccessExpression(node.expression) &&
isMobileI18nInstance(node.expression.expression, bindings) &&
node.expression.name.text === 'getFixedT' &&
isFixedTranslatorFactory(node.expression, bindings) &&
node.arguments.length === 1 &&
ts.isStringLiteralLike(node.arguments[0]) &&
node.arguments[0].text === 'en'
@@ -188,26 +90,31 @@ function directTranslatorPrefix(callee, bindings) {
}
export function collectMobileTranslationBindings(sourceFile) {
const declarations = collectDeclarations(sourceFile)
const sourceBindings = collectSourceBindings(sourceFile)
const translatorBindings = new Set()
const factoryBindings = new Set()
const fixedFactoryBindings = new Set()
const instanceBindings = new Set()
const namespaceBindings = new Set()
const fixedTranslatorBindings = new Set()
const prefixedTranslatorBindings = new Map()
const translatorNames = new Set()
const fixedTranslatorNames = new Set()
const instanceNames = new Set()
const namespaceNames = new Set()
const prefixedTranslatorNames = new Map()
const bindings = {
declarations,
factoryBindings,
fixedFactoryBindings,
fixedTranslatorBindings,
fixedTranslatorNames,
instanceBindings,
instanceNames,
namespaceBindings,
namespaceNames,
prefixedTranslatorBindings,
prefixedTranslatorNames,
resolveBinding: (identifier) => resolveBinding(identifier, sourceFile, declarations),
resolveBinding: sourceBindings.resolveBinding,
translatorBindings,
translatorNames
}
@@ -223,6 +130,7 @@ export function collectMobileTranslationBindings(sourceFile) {
const namedBindings = statement.importClause?.namedBindings
if (namedBindings && ts.isNamespaceImport(namedBindings)) {
namespaceBindings.add(namedBindings)
namespaceNames.add(namedBindings.name.text)
continue
}
if (!namedBindings || !ts.isNamedImports(namedBindings)) {
@@ -237,18 +145,71 @@ export function collectMobileTranslationBindings(sourceFile) {
factoryBindings.add(element)
} else if (importedName === 'mobileI18n') {
instanceBindings.add(element)
instanceNames.add(element.name.text)
}
}
}
function addBinding(identifier, bindingSet, nameSet) {
const binding = bindings.resolveBinding(identifier)
if (!binding) {
return
}
bindingSet.add(binding)
nameSet?.add(identifier.text)
}
function collectDestructuredAliases(node) {
if (!ts.isObjectBindingPattern(node.name) || !node.initializer) {
return
}
const namespaceSource = isMobileI18nNamespace(node.initializer, bindings)
const instanceSource = isMobileI18nInstance(node.initializer, bindings)
if (!namespaceSource && !instanceSource) {
return
}
for (const element of node.name.elements) {
if (!ts.isIdentifier(element.name) || element.dotDotDotToken) {
continue
}
const propertyName = element.propertyName ?? element.name
if (!ts.isIdentifier(propertyName) && !ts.isStringLiteralLike(propertyName)) {
continue
}
if (propertyName.text === 't') {
addBinding(element.name, translatorBindings, translatorNames)
} else if (namespaceSource && propertyName.text === 'createMobileTranslator') {
addBinding(element.name, factoryBindings)
} else if (namespaceSource && propertyName.text === 'mobileI18n') {
addBinding(element.name, instanceBindings, instanceNames)
} else if (instanceSource && propertyName.text === 'getFixedT') {
addBinding(element.name, fixedFactoryBindings)
}
}
}
function collectLocalTranslators(node) {
if (ts.isVariableDeclaration(node)) {
collectDestructuredAliases(node)
}
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
const binding = bindings.resolveBinding(node.name)
if (isTranslatorFactory(node.initializer, bindings)) {
if (!binding) {
ts.forEachChild(node, collectLocalTranslators)
return
}
if (isMobileI18nNamespace(node.initializer, bindings)) {
namespaceBindings.add(binding)
namespaceNames.add(node.name.text)
} else if (isTranslatorFactory(node.initializer, bindings)) {
factoryBindings.add(binding)
}
if (isFixedTranslatorFactory(node.initializer, bindings)) {
fixedFactoryBindings.add(binding)
}
if (isMobileI18nInstance(node.initializer, bindings)) {
instanceBindings.add(binding)
instanceNames.add(node.name.text)
}
if (isEnglishFixedTranslator(node.initializer, bindings)) {
fixedTranslatorBindings.add(binding)
@@ -11,6 +11,10 @@ import {
isMobileTranslationCall,
mobileTranslationCallPrefix
} from './mobile-localization-translation-bindings.mjs'
import {
PRODUCT_GLOSSARY,
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')]
@@ -249,11 +253,11 @@ function occurrenceCount(value, literal) {
return value.split(literal).length - 1
}
function verifyProtectedLiterals(englishValue, localeValue, locale, key) {
function verifyProtectedLiterals(englishValue, localeValue, catalogName, key) {
const issues = []
for (const literal of PROTECTED_LITERALS) {
if (occurrenceCount(localeValue, literal) < occurrenceCount(englishValue, literal)) {
issues.push(`${locale}.json protected literal mismatch: ${key} must preserve ${literal}`)
issues.push(`${catalogName} protected literal mismatch: ${key} must preserve ${literal}`)
}
}
return issues
@@ -275,20 +279,24 @@ function isLanguageNeutralValue(value, locale) {
)
}
function verifyTerminology(englishValue, localeValue, locale, key) {
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(`${locale}.json Git terminology mismatch: ${key} translates branch as sucursal`)
issues.push(`${catalogName} Git terminology mismatch: ${key} translates branch as sucursal`)
}
if (locale === 'zh' && /\bhost\b/i.test(englishValue) && localeValue.includes('主人')) {
issues.push(`${locale}.json host terminology mismatch: ${key} uses 主人`)
issues.push(`${catalogName} host terminology mismatch: ${key} uses 主人`)
}
if (locale === 'zh' && /\bagent/i.test(englishValue) && localeValue.includes('检测剂')) {
issues.push(`${locale}.json agent terminology mismatch: ${key} uses 检测剂`)
issues.push(`${catalogName} agent terminology mismatch: ${key} uses 检测剂`)
}
const expectedValue = PRODUCT_GLOSSARY.get(englishValue)?.[locale]
if (expectedValue && localeValue !== expectedValue) {
issues.push(`${catalogName} product terminology mismatch: ${key} must use ${expectedValue}`)
}
return issues
}
@@ -379,29 +387,41 @@ function verifyEnglishEntries(calls, englishEntries) {
return issues
}
function verifyLocaleEntries(englishEntries, locale, localeEntries) {
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(`${locale}.json has extra key: ${key}`)
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(`${locale}.json placeholder mismatch: ${key}`)
issues.push(`${catalogName} placeholder mismatch: ${key}`)
}
if (EXACT_LANGUAGE_NEUTRAL_VALUES.has(englishValue) && localeValue !== englishValue) {
issues.push(`${locale}.json must preserve language-neutral value: ${key}`)
issues.push(`${catalogName} must preserve language-neutral value: ${key}`)
}
issues.push(...verifyProtectedLiterals(englishValue, localeValue, locale, key))
issues.push(...verifyTerminology(englishValue, localeValue, locale, key))
issues.push(...verifyProtectedLiterals(englishValue, localeValue, catalogName, key))
issues.push(...verifyTerminology(englishValue, localeValue, locale, catalogName, key))
if (localeValue === englishValue && !isLanguageNeutralValue(englishValue, locale)) {
issues.push(`${locale}.json copies English instead of using fallback: ${key}`)
issues.push(`${catalogName} copies English instead of using fallback: ${key}`)
}
}
@@ -516,6 +536,21 @@ async function verifyNativeCatalogs(root) {
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
}
@@ -22,6 +22,22 @@ const NATIVE_CATALOG = {
android: { app_name: 'Orca' }
}
function nativeCatalogFor(locale) {
if (locale === 'en') {
return NATIVE_CATALOG
}
return {
ios: {
CFBundleDisplayName: 'Orca',
NSCameraUsageDescription: `${locale} Camera fallback`,
NSLocalNetworkUsageDescription: `${locale} Network fallback`,
NSMicrophoneUsageDescription: `${locale} Microphone fallback`,
NSPhotoLibraryUsageDescription: `${locale} Photo fallback`
},
android: { app_name: 'Orca' }
}
}
function defaultAppConfig() {
return {
expo: {
@@ -79,7 +95,7 @@ function makeProject({
for (const locale of NATIVE_LOCALES) {
writeJson(
path.join(nativeLocaleDirectory, `${locale}.json`),
nativeCatalogs?.[locale] ?? NATIVE_CATALOG
nativeCatalogs?.[locale] ?? nativeCatalogFor(locale)
)
}
return root
@@ -214,6 +230,35 @@ export const labels = [
])
})
it('tracks namespace aliases and destructured translation bindings', () => {
const sourceText = `
import { mobileI18n } from '@/i18n/mobile-i18n'
import * as i18n from '@/i18n/mobile-i18n'
const localI18n = i18n
const { t: instanceT, getFixedT } = mobileI18n
const { t: namespaceT, createMobileTranslator: make, mobileI18n: aliasedInstance } = localI18n
const { t: aliasedInstanceT } = aliasedInstance
const prefixed = make('example')
const fixed = getFixedT('en')
export const labels = [
instanceT('example.instance', { name: 'Orca' }),
namespaceT('example.namespace'),
aliasedInstanceT('example.aliasedInstance'),
prefixed('prefixed'),
fixed('example.fixed')
]
`
const calls = collectMobileTranslationCalls('/repo/mobile/app/Example.tsx', sourceText, '/repo')
expect(calls.map(({ keys, options }) => ({ keys, options: options.names }))).toEqual([
{ keys: ['example.instance'], options: ['name'] },
{ keys: ['example.namespace'], options: [] },
{ keys: ['example.aliasedInstance'], options: [] },
{ keys: ['example.prefixed'], options: [] },
{ keys: ['example.fixed'], options: [] }
])
})
it('limits for-loop and catch bindings to their lexical scopes', () => {
const sourceText = `
import { t } from '@/i18n/mobile-i18n'
@@ -249,7 +294,7 @@ export const afterCatch = t('example.afterCatch')
it('requires call options to exactly match English placeholders', async () => {
const root = makeProject({
sourceText:
"import { t } from '@/i18n/mobile-i18n'\nexport const label = t('m.greeting', { value: 'Orca' })\n",
"import { mobileI18n } from '@/i18n/mobile-i18n'\nconst { t: translate } = mobileI18n\nexport const label = translate('m.greeting', { value: 'Orca' })\n",
catalogs: { en: { m: { greeting: 'Hello {{name}}' } } }
})
@@ -272,6 +317,36 @@ export const afterCatch = t('example.afterCatch')
await expect(verifyMobileCatalog(root)).resolves.toBe(0)
})
it('requires every target locale to translate composed PR status fragments', async () => {
const root = makeProject({
sourceText: `
import { t } from '@/i18n/mobile-i18n'
export const labels = [
t('pullRequest.checks.running', { count: 1 }),
t('pullRequest.unresolvedComments.one', { count: 1 }),
t('pullRequest.unresolvedComments.other', { count: 2 })
]
`,
catalogs: {
en: {
pullRequest: {
checks: { running: '{{count}} running' },
unresolvedComments: {
one: ', {{count}} unresolved comment',
other: ', {{count}} unresolved comments'
}
}
}
}
})
const report = await runFailedVerification(root)
expect(report).toContain('es.json missing required translation: pullRequest.checks.running')
expect(report).toContain(
'zh.json missing required translation: pullRequest.unresolvedComments.other'
)
})
it('rejects opaque IDs, positional placeholders, and orphaned English keys', async () => {
const root = makeProject({
sourceText:
@@ -371,6 +446,46 @@ export const afterCatch = t('example.afterCatch')
expect(report).toContain('uses 检测剂')
})
it('enforces the mobile Git and product glossary', async () => {
const root = makeProject({
sourceText: `
import { t } from '@/i18n/mobile-i18n'
export const labels = [
t('example.sparse'),
t('example.detecting'),
t('example.loadingTerminal'),
t('example.openSource')
]
`,
catalogs: {
en: {
example: {
sparse: 'Sparse Checkout',
detecting: 'Detecting Agents',
loadingTerminal: 'Loading terminal',
openSource: 'Open source control'
}
},
es: {
example: {
sparse: 'Pago escaso',
detecting: 'Agents de detección',
loadingTerminal: 'terminal de carga',
openSource: 'Control de código abierto'
}
},
ja: { example: { detecting: '検出剤' } },
zh: { example: { sparse: '稀疏结帐', loadingTerminal: '装货码头' } }
}
})
const report = await runFailedVerification(root)
expect(report).toContain('es.json product terminology mismatch: example.sparse')
expect(report).toContain('ja.json product terminology mismatch: example.detecting')
expect(report).toContain('zh.json product terminology mismatch: example.loadingTerminal')
expect(report).toContain('es.json product terminology mismatch: example.openSource')
})
it('validates placeholders and extra keys in present translations', async () => {
const en = { m: { greeting: 'Hello {{name}}' } }
const root = makeProject({
@@ -453,4 +568,31 @@ export const afterCatch = t('example.afterCatch')
'mobile/app.json English native fallback mismatch: ios.NSCameraUsageDescription'
)
})
it('checks native translations for copied English, literals, and placeholders', async () => {
const englishNative = structuredClone(NATIVE_CATALOG)
englishNative.ios.NSCameraUsageDescription = 'Allow Orca to use {{resource}}'
const spanishNative = nativeCatalogFor('es')
spanishNative.ios.NSCameraUsageDescription = 'Permite que Orka use {{wrong}}'
spanishNative.ios.NSLocalNetworkUsageDescription = 'Network fallback'
const appConfig = defaultAppConfig()
appConfig.expo.plugins[1][1].cameraPermission = englishNative.ios.NSCameraUsageDescription
const root = makeProject({
sourceText: "export const label = 'not rendered'\n",
catalogs: { en: { m: { label: 'Visible' } } },
nativeCatalogs: { en: englishNative, es: spanishNative },
appConfig
})
const report = await runFailedVerification(root)
expect(report).toContain(
'mobile/locales/es.json placeholder mismatch: ios.NSCameraUsageDescription'
)
expect(report).toContain(
'mobile/locales/es.json protected literal mismatch: ios.NSCameraUsageDescription must preserve Orca'
)
expect(report).toContain(
'mobile/locales/es.json copies English instead of using fallback: ios.NSLocalNetworkUsageDescription'
)
})
})
+1 -1
View File
@@ -36,7 +36,7 @@ function modelMeta(model: MobileSpeechModel): string {
if (model.status === 'extracting') {
return [formatSize(model.sizeBytes), t('mobileDictationSetupSheet.extracting')]
.filter(Boolean)
.join(' · ')
.join(' ')
}
return formatSize(model.sizeBytes)
}
+11 -4
View File
@@ -152,7 +152,7 @@
"newPreset": "Nuevo preset",
"useWhole": "Utilice todo el repositorio",
"full": "Checkout completo",
"sparse": "Pago escaso",
"sparse": "Checkout disperso",
"noBranches": "Ninguna rama coincide.",
"useRepositorys": "Utilice la base configurada de este repositorio",
"defaultBranch": "Rama predeterminada",
@@ -610,7 +610,7 @@
"back": "Volver a los árboles de trabajo",
"agent": "Presets de Agent no disponibles",
"noEnabled": "No hay Agents habilitados",
"detecting": "Agents de detección",
"detecting": "Detectando agentes",
"browserPage": "La página del navegador aún no está disponible.",
"desktopUpdateRequiredMobile": "Se requiere actualización de escritorio para la transmisión del navegador móvil",
"edit": "Edite este comando rápido antes de ejecutarlo",
@@ -659,11 +659,11 @@
"switchDesktop": "Cambiar al modo de escritorio",
"hides": "Oculta el teclado del software y mantiene abierta la sesión actual del terminal.",
"dismissKeyboard": "Descartar teclado",
"loadingTerminal": "terminal de carga",
"loadingTerminal": "Cargando terminal",
"checking": "Comprobando las capacidades del escritorio: inténtalo de nuevo en un momento",
"desktopUpdateRequiredQuick": "Se requiere actualización de escritorio para comandos rápidos",
"newTab": "Nueva pestaña",
"openSource": "Control de código abierto",
"openSource": "Abrir control de código fuente",
"openFile": "Abrir explorador de archivos",
"couldNotSend": "No se pudieron enviar notas",
"notesSent": "Notas enviadas",
@@ -2478,6 +2478,13 @@
}
},
"pullRequest": {
"checks": {
"running": "{{count}} en curso"
},
"unresolvedComments": {
"one": ", {{count}} comentario sin resolver",
"other": ", {{count}} comentarios sin resolver"
},
"conflicts": {
"fileDetailsUnavailable": "Los detalles de los archivos en conflicto no están disponibles",
"refreshing": "Actualizando los detalles de los conflictos…",
+10 -3
View File
@@ -624,7 +624,7 @@
"back": "ワークツリーに戻る",
"agent": "Agent のプリセットは利用できません",
"noEnabled": "有効な Agents がありません",
"detecting": "検出剤",
"detecting": "エージェントを検出中",
"browserPage": "ブラウザ ページはまだ利用できません。",
"desktopUpdateRequiredMobile": "モバイルブラウザストリーミングにはデスクトップのアップデートが必要です",
"edit": "このクイックコマンドを実行する前に編集してください",
@@ -673,11 +673,11 @@
"switchDesktop": "デスクトップモードに切り替える",
"hides": "ソフトウェア キーボードを非表示にし、現在の terminal セッションを開いたままにします。",
"dismissKeyboard": "キーボードを閉じる",
"loadingTerminal": "ローディング terminal",
"loadingTerminal": "ターミナルを読み込み中",
"checking": "デスクトップの機能をチェックしています - しばらくしてからもう一度お試しください",
"desktopUpdateRequiredQuick": "クイックコマンドを実行するにはデスクトップのアップデートが必要です",
"newTab": "新規タブ",
"openSource": "オープンソース管理",
"openSource": "ソース管理を開く",
"openFile": "ファイルエクスプローラーを開く",
"couldNotSend": "メモを送信できませんでした",
"notesSent": "送信されたメモ",
@@ -2521,6 +2521,13 @@
}
},
"pullRequest": {
"checks": {
"running": "{{count}} 件実行中"
},
"unresolvedComments": {
"one": "、未解決のコメント {{count}} 件",
"other": "、未解決のコメント {{count}} 件"
},
"conflicts": {
"fileDetailsUnavailable": "競合ファイルの詳細を取得できません",
"refreshing": "競合の詳細を更新しています…",
+10 -3
View File
@@ -624,7 +624,7 @@
"back": "작업 트리로 돌아가기",
"agent": "Agent 사전 설정을 사용할 수 없음",
"noEnabled": "활성화된 Agents 없음",
"detecting": "Agents 감지",
"detecting": "에이전트 감지 중",
"browserPage": "브라우저 페이지는 아직 사용할 수 없습니다.",
"desktopUpdateRequiredMobile": "모바일 브라우저 스트리밍을 위해서는 데스크톱 업데이트가 필요합니다.",
"edit": "실행하기 전에 이 빠른 명령을 편집하세요",
@@ -673,11 +673,11 @@
"switchDesktop": "데스크톱 모드로 전환",
"hides": "소프트웨어 키보드를 숨기고 현재 terminal 세션을 열어 둡니다.",
"dismissKeyboard": "키보드 닫기",
"loadingTerminal": "로딩 terminal",
"loadingTerminal": "터미널 불러오는 중",
"checking": "데스크톱 기능 확인 중 — 잠시 후 다시 시도하세요",
"desktopUpdateRequiredQuick": "빠른 명령을 사용하려면 데스크탑 업데이트가 필요합니다.",
"newTab": "새 탭",
"openSource": "오픈소스 제어",
"openSource": "소스 제어 열기",
"openFile": "파일 탐색기 열기",
"couldNotSend": "메모를 보낼 수 없습니다.",
"notesSent": "보낸 메모",
@@ -2516,6 +2516,13 @@
}
},
"pullRequest": {
"checks": {
"running": "{{count}}개 실행 중"
},
"unresolvedComments": {
"one": ", 해결되지 않은 댓글 {{count}}개",
"other": ", 해결되지 않은 댓글 {{count}}개"
},
"conflicts": {
"fileDetailsUnavailable": "충돌 파일 세부 정보를 사용할 수 없습니다",
"refreshing": "충돌 세부 정보 새로 고치는 중…",
+10 -3
View File
@@ -157,7 +157,7 @@
"newPreset": "新预设",
"useWhole": "使用整个存储库",
"full": "完整检出",
"sparse": "稀疏结帐",
"sparse": "稀疏检出",
"noBranches": "没有分支匹配。",
"useRepositorys": "使用此存储库的配置基础",
"defaultBranch": "默认分支",
@@ -673,11 +673,11 @@
"switchDesktop": "切换到桌面模式",
"hides": "隐藏软件键盘并保持当前 terminal 会话打开。",
"dismissKeyboard": "关闭键盘",
"loadingTerminal": "装货码头",
"loadingTerminal": "正在加载终端",
"checking": "检查桌面功能 - 稍后重试",
"desktopUpdateRequiredQuick": "快速命令需要更新桌面",
"newTab": "新标签页",
"openSource": "开源控制",
"openSource": "打开源代码管理",
"openFile": "打开文件资源管理器",
"couldNotSend": "无法发送笔记",
"notesSent": "已发送注释",
@@ -2529,6 +2529,13 @@
}
},
"pullRequest": {
"checks": {
"running": "{{count}} 项运行中"
},
"unresolvedComments": {
"one": ",{{count}} 条未解决评论",
"other": ",{{count}} 条未解决评论"
},
"conflicts": {
"fileDetailsUnavailable": "无法获取冲突文件详情",
"refreshing": "正在刷新冲突详情…",