mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(mobile): close localization audit bypasses
This commit is contained in:
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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": "競合の詳細を更新しています…",
|
||||
|
||||
@@ -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": "충돌 세부 정보 새로 고치는 중…",
|
||||
|
||||
@@ -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": "正在刷新冲突详情…",
|
||||
|
||||
Reference in New Issue
Block a user