diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 9e7c73c028b..e6f28e7fa22 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -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)) diff --git a/config/scripts/mobile-localization-candidate-rules.mjs b/config/scripts/mobile-localization-candidate-rules.mjs index 94a891571e0..c6e25f65f1d 100644 --- a/config/scripts/mobile-localization-candidate-rules.mjs +++ b/config/scripts/mobile-localization-candidate-rules.mjs @@ -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' } diff --git a/config/scripts/mobile-localization-candidate-rules.test.mjs b/config/scripts/mobile-localization-candidate-rules.test.mjs index 436ad7a2e22..53b750e1783 100644 --- a/config/scripts/mobile-localization-candidate-rules.test.mjs +++ b/config/scripts/mobile-localization-candidate-rules.test.mjs @@ -178,7 +178,8 @@ function projectRowType() { return 'issue' } export function Example() { - return <>{projectRowType() ? {lookup()} : null} + const render = lookup + return <>{projectRowType() ? {render()} : 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 {translateMobile('Unlocalized shadowed copy')} } +export function MemberExample(i18n) { + return {i18n.t('Unlocalized shadowed member copy')} +} ` 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 () => { diff --git a/config/scripts/mobile-localization-product-glossary.mjs b/config/scripts/mobile-localization-product-glossary.mjs new file mode 100644 index 00000000000..9de023a5054 --- /dev/null +++ b/config/scripts/mobile-localization-product-glossary.mjs @@ -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: '打开源代码管理' + } + ] +]) diff --git a/config/scripts/mobile-localization-rendered-variable.mjs b/config/scripts/mobile-localization-rendered-variable.mjs index 16823de3245..b346132f118 100644 --- a/config/scripts/mobile-localization-rendered-variable.mjs +++ b/config/scripts/mobile-localization-rendered-variable.mjs @@ -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 } diff --git a/config/scripts/mobile-localization-source-bindings.mjs b/config/scripts/mobile-localization-source-bindings.mjs new file mode 100644 index 00000000000..00e43e2e254 --- /dev/null +++ b/config/scripts/mobile-localization-source-bindings.mjs @@ -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 + } + } +} diff --git a/config/scripts/mobile-localization-translation-bindings.mjs b/config/scripts/mobile-localization-translation-bindings.mjs index 8ea87e1f2e4..176dd20cf9d 100644 --- a/config/scripts/mobile-localization-translation-bindings.mjs +++ b/config/scripts/mobile-localization-translation-bindings.mjs @@ -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) diff --git a/config/scripts/verify-mobile-localization-catalog.mjs b/config/scripts/verify-mobile-localization-catalog.mjs index 6d61156b953..197a430ed72 100644 --- a/config/scripts/verify-mobile-localization-catalog.mjs +++ b/config/scripts/verify-mobile-localization-catalog.mjs @@ -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 } diff --git a/config/scripts/verify-mobile-localization-catalog.test.mjs b/config/scripts/verify-mobile-localization-catalog.test.mjs index e30a4889521..6bb4417de01 100644 --- a/config/scripts/verify-mobile-localization-catalog.test.mjs +++ b/config/scripts/verify-mobile-localization-catalog.test.mjs @@ -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' + ) + }) }) diff --git a/mobile/src/components/VoiceModelList.tsx b/mobile/src/components/VoiceModelList.tsx index 1832594848c..370607f8e11 100644 --- a/mobile/src/components/VoiceModelList.tsx +++ b/mobile/src/components/VoiceModelList.tsx @@ -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) } diff --git a/mobile/src/i18n/locales/es.json b/mobile/src/i18n/locales/es.json index 4926a8a487d..ffcb2f075bd 100644 --- a/mobile/src/i18n/locales/es.json +++ b/mobile/src/i18n/locales/es.json @@ -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…", diff --git a/mobile/src/i18n/locales/ja.json b/mobile/src/i18n/locales/ja.json index 8d385d322c8..500c120d992 100644 --- a/mobile/src/i18n/locales/ja.json +++ b/mobile/src/i18n/locales/ja.json @@ -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": "競合の詳細を更新しています…", diff --git a/mobile/src/i18n/locales/ko.json b/mobile/src/i18n/locales/ko.json index a8c0adb49f8..a541c92ead0 100644 --- a/mobile/src/i18n/locales/ko.json +++ b/mobile/src/i18n/locales/ko.json @@ -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": "충돌 세부 정보 새로 고치는 중…", diff --git a/mobile/src/i18n/locales/zh.json b/mobile/src/i18n/locales/zh.json index 77433a3ea2c..dbbb2fd2189 100644 --- a/mobile/src/i18n/locales/zh.json +++ b/mobile/src/i18n/locales/zh.json @@ -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": "正在刷新冲突详情…",