diff --git a/config/scripts/locale-generic-ui-terms.mjs b/config/scripts/locale-generic-ui-terms.mjs new file mode 100644 index 000000000000..2b41a324ce64 --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.mjs @@ -0,0 +1,102 @@ +// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate +// these, so the policy must neither pin the whole value to English nor revert the translation +// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin +// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word. + +// Renderings a locale is expected to use. They are translations, never machine-translation +// errors, so a brand revert must leave them alone. Nonsense forms that share the same English +// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse") +// stay in BRAND_MISTRANSLATIONS and keep getting reverted. +const GENERIC_TERM_FAMILIES = [ + { + terms: ['Agent', 'Agents', 'agent', 'agents'], + renderings: { + ko: ['에이전트'], + ja: ['エージェント'], + zh: ['代理', '智能体'], + es: ['Agente', 'agente', 'Agentes', 'agentes'] + } + }, + { + terms: ['Commit', 'Commits', 'commit', 'commits'], + renderings: { + ko: ['커밋'], + ja: ['コミット'], + zh: ['提交'], + es: [ + 'Confirmación', + 'confirmación', + 'Confirmaciones', + 'confirmaciones', + 'Confirmar', + 'confirmar' + ] + } + }, + { + terms: ['Continue'], + renderings: { + ko: ['계속하다', '계속'], + ja: ['続ける', '続行'], + zh: ['继续'], + es: ['Continuar', 'continuar'] + } + }, + { + terms: ['Repo', 'Repos', 'repo', 'repos'], + renderings: { + ko: ['저장소', '레포'], + ja: ['リポジトリ', 'リポ'], + zh: ['存储库', '仓库'], + es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios'] + } + }, + { + terms: ['Terminal', 'Terminals', 'terminal', 'terminals'], + renderings: { + ko: ['터미널'], + ja: ['ターミナル'], + zh: ['终端'], + es: ['Terminales', 'terminales'] + } + } +] + +export const LOCALIZABLE_GENERIC_TERMS = new Set( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms) +) + +const RENDERINGS_BY_TERM = new Map( + GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings])) +) + +export function isLocalizableGenericTerm(term) { + return LOCALIZABLE_GENERIC_TERMS.has(term) +} + +export function isCanonicalGenericRendering(term, locale, form) { + return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false +} + +export function canonicalGenericRenderings(locale) { + return GENERIC_TERM_FAMILIES.flatMap((family) => + (family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms })) + ) +} + +function spansOf(value, needle) { + const spans = [] + for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) { + spans.push([at, at + needle.length]) + } + return spans +} + +// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the +// electrical-connector mistranslation would cut a valid word in half. +export function overlapsCanonicalRendering(term, locale, value, start, end) { + const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? [] + return renderings.some((form) => + spansOf(value, form).some(([from, to]) => start < to && from < end) + ) +} diff --git a/config/scripts/locale-generic-ui-terms.test.mjs b/config/scripts/locale-generic-ui-terms.test.mjs new file mode 100644 index 000000000000..42a7d63d0b3b --- /dev/null +++ b/config/scripts/locale-generic-ui-terms.test.mjs @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' + +import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs' +import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs' + +// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because +// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin +// the boundary: generic words stay translated, real brand names stay Latin. +describe('locale generic UI terms', () => { + it('keeps a translated generic term when the whole value is that term', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AppearancePane.terminalTitle', + enValue: 'Terminal', + localeValue: '터미널', + locale: 'ko' + }) + ).toBe('터미널') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.QuickCommandsPane.4ccc63da87', + enValue: 'Agent', + localeValue: '에이전트', + locale: 'ko' + }) + ).toBe('에이전트') + expect( + repairTranslatedValue({ + key: 'auto.components.mobile.MobileHero.a8fb43cf1c', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('继续') + }) + + it('keeps a translated generic term inside a sentence', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.settings.EditorFontSection.matchTerminal', + enValue: 'Match terminal font', + localeValue: '터미널 글꼴과 동일', + locale: 'ko' + }) + ).toBe('터미널 글꼴과 동일') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel', + enValue: 'Toggle Agent Dashboard', + localeValue: '에이전트 대시보드 전환', + locale: 'ko' + }) + ).toBe('에이전트 대시보드 전환') + expect( + repairTranslatedValue({ + key: 'components.agentSessionContinuation.dialogTitle', + enValue: 'Continue in New Session', + localeValue: '新しいセッションで続ける', + locale: 'ja' + }) + // 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive. + ).toBe('新規セッションで続ける') + }) + + it('still reverts real brand names that machine translation localized', () => { + expect( + repairTranslatedValue({ + key: 'auto.stats.StatsPane.7d26110cea', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.appearance.search.9ae151b26b', + enValue: 'linear', + localeValue: '线性', + locale: 'zh' + }) + ).toBe('Linear') + expect( + repairTranslatedValue({ + key: 'auto.components.settings.NetworkPane.tailscale', + enValue: 'Connect over Tailscale', + localeValue: '通过尾鳞连接', + locale: 'zh' + }) + ).toBe('通过 Tailscale 连接') + }) + + it('still reverts nonsense renderings of a generic term', () => { + // 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge". + expect( + repairTranslatedValue({ + key: 'auto.components.agent.AgentCombobox.986f946354', + enValue: 'Blank Terminal', + localeValue: '空白端子', + locale: 'zh' + }) + ).toBe('空白 Terminal') + expect( + repairTranslatedValue({ + key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f', + enValue: 'Commit', + localeValue: 'Comprometerse', + locale: 'es' + }) + ).toBe('Commit') + expect( + repairTranslatedValue({ + key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', + enValue: 'Markdown', + localeValue: '가격 인하', + locale: 'ko' + }) + ).toBe('Markdown') + }) + + it('does not cut a valid word that contains a nonsense rendering', () => { + // 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子. + expect( + repairTranslatedValue({ + key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1', + enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.', + localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。', + locale: 'zh' + }) + ).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。') + }) + + it('still fills in a translation when the catalog holds English', () => { + expect( + repairTranslatedValue({ + key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', + enValue: 'Terminal 1', + localeValue: 'Terminal 1', + locale: 'ko' + }) + ).toBe('터미널 1') + expect( + repairTranslatedValue({ + key: 'auto.store.slices.worktrees.889487d8bb', + enValue: 'Dismiss', + localeValue: '해고하다', + locale: 'ko' + }) + ).toBe('닫기') + }) + + it('keeps agent catalog product names in English by key, not by word', () => { + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.9e2a9bb87b', + enValue: 'Continue', + localeValue: '继续', + locale: 'zh' + }) + ).toBe('Continue') + expect( + repairTranslatedValue({ + key: 'auto.lib.agent.catalog.760bc6883d', + enValue: 'Codex', + localeValue: '사본', + locale: 'ko' + }) + ).toBe('Codex') + }) + + it('does not list a generic term as never-translatable', () => { + const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term)) + expect(pinned).toEqual([]) + }) +}) diff --git a/config/scripts/locale-ja-value-overrides.mjs b/config/scripts/locale-ja-value-overrides.mjs index 7f9eeb60af73..908626f34f3d 100644 --- a/config/scripts/locale-ja-value-overrides.mjs +++ b/config/scripts/locale-ja-value-overrides.mjs @@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = { 'Pick a base branch below': '以下のベースブランチを選択', 'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択', 'Local project, Git repo, or folder with many repos': - 'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー', + 'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', 'Enter passphrase': 'パスフレーズを入力', 'Enter password': 'パスワードを入力', 'Enter the passphrase for': 'のパスフレーズを入力', diff --git a/config/scripts/locale-ko-key-overrides.json b/config/scripts/locale-ko-key-overrides.json index 9b40ad61f503..ee9f70acd564 100644 --- a/config/scripts/locale-ko-key-overrides.json +++ b/config/scripts/locale-ko-key-overrides.json @@ -2727,7 +2727,7 @@ "ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다." }, "auto.components.settings.SshTargetForm.137e88ce8d": { - "ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:" + "ko": "연결 해제 후 릴레이가 터미널을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:" }, "auto.components.settings.SshTargetForm.2ee9bcd2e8": { "ko": "server, deploy@server:2222, ssh://server" diff --git a/config/scripts/locale-ko-value-overrides.mjs b/config/scripts/locale-ko-value-overrides.mjs index 1d128a67897c..19db7c753ec5 100644 --- a/config/scripts/locale-ko-value-overrides.mjs +++ b/config/scripts/locale-ko-value-overrides.mjs @@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = { 'Install the Orca skill so agents know to use the Orca CLI.': '에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.', 'Local project, Git repo, or folder with many repos': - '로컬 프로젝트, Git repo 또는 repos가 많은 폴더', + '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.': 'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.', 'changed since you last approved. Re-review before it runs': diff --git a/config/scripts/locale-prose-term-exemptions.mjs b/config/scripts/locale-prose-term-exemptions.mjs deleted file mode 100644 index fbe2d483fa1f..000000000000 --- a/config/scripts/locale-prose-term-exemptions.mjs +++ /dev/null @@ -1,19 +0,0 @@ -const LOCALIZED_PROSE_TERM_KEYS = new Set([ - 'auto.hooks.useMacosTccPromptNotice.description', - 'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8' -]) - -const LOCALIZABLE_PROSE_TERMS = new Set([ - 'Agent', - 'Agents', - 'agent', - 'agents', - 'Terminal', - 'Terminals', - 'terminal', - 'terminals' -]) - -export function isLocalizedProseTermContext(term, key) { - return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term) -} diff --git a/config/scripts/locale-translation-policy-ko-round5.test.mjs b/config/scripts/locale-translation-policy-ko-round5.test.mjs index b3f88e4e1414..e03157e0bc60 100644 --- a/config/scripts/locale-translation-policy-ko-round5.test.mjs +++ b/config/scripts/locale-translation-policy-ko-round5.test.mjs @@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.', locale: 'ko' }) - ).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') + ).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.') expect( repairTranslatedValue({ key: 'auto.components.editor.MarkdownPreview.322afab6ff', @@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => { ).toBe('파이프라인') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '터미널 1', locale: 'ko' }) - ).toBe('Terminal 1') + ).toBe('터미널 1') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '에이전트 스킬', locale: 'ko' }) - ).toBe('Agent 스킬') + ).toBe('에이전트 스킬') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '푸시되지 않은 커밋', locale: 'ko' }) - ).toBe('푸시되지 않은 commits') + ).toBe('푸시되지 않은 커밋') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '레포', locale: 'ko' }) - ).toBe('Repo') + ).toBe('레포') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => { localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더', locale: 'ko' }) - ).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더') + ).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더') }) it('re-glues Korean particles after Latin terms without gluing content words', () => { diff --git a/config/scripts/locale-translation-policy.es-round5.test.mjs b/config/scripts/locale-translation-policy.es-round5.test.mjs index b52afceb02d3..3bf5a77fb3a0 100644 --- a/config/scripts/locale-translation-policy.es-round5.test.mjs +++ b/config/scripts/locale-translation-policy.es-round5.test.mjs @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { repairTranslatedValue } from './locale-translation-policy.mjs' describe('locale-translation-policy es round 5', () => { - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'mensaje de confirmación', locale: 'es' }) - ).toBe('mensaje de Commit') + ).toBe('mensaje de confirmación') expect( repairTranslatedValue({ key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a', @@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'repositorio', locale: 'es' }) - ).toBe('Repo') + ).toBe('repositorio') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => { localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios', locale: 'es' }) - ).toBe('Proyecto local, repo de Git o carpeta con muchos repos') + ).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios') }) }) diff --git a/config/scripts/locale-translation-policy.ja-round5.test.mjs b/config/scripts/locale-translation-policy.ja-round5.test.mjs index 3100efc3abea..5ee16fad1d82 100644 --- a/config/scripts/locale-translation-policy.ja-round5.test.mjs +++ b/config/scripts/locale-translation-policy.ja-round5.test.mjs @@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => { ).toBe('まずプロジェクトを追加') }) - it('keeps protected workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3', @@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'エージェントのスキル', locale: 'ja' }) - ).toBe('Agent のスキル') + ).toBe('エージェントのスキル') expect( repairTranslatedValue({ key: 'auto.components.tab.bar.TabBar.3d5d6c960d', @@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'コミット', locale: 'ja' }) - ).toBe('commits') + ).toBe('コミット') expect( repairTranslatedValue({ key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df', @@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => { localeValue: 'リポ', locale: 'ja' }) - ).toBe('Repo') + ).toBe('リポ') expect( repairTranslatedValue({ key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e', @@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => { 'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー', locale: 'ja' }) - ).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー') + ).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー') }) }) diff --git a/config/scripts/locale-translation-policy.mjs b/config/scripts/locale-translation-policy.mjs index a3feb5f59dfd..cd579edb38ee 100644 --- a/config/scripts/locale-translation-policy.mjs +++ b/config/scripts/locale-translation-policy.mjs @@ -1,5 +1,8 @@ import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs' -import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs' +import { + isCanonicalGenericRendering, + overlapsCanonicalRendering +} from './locale-generic-ui-terms.mjs' import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs' import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs' import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs' @@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.' // Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac). export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX] +// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to +// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs +// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead. export const NEVER_TRANSLATE_VALUES = new Set([ - 'Agent', - 'Agents', 'Aider', 'Amp', 'Android', @@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Codebuff', 'Codex', 'Command Code', - 'Continue', 'Cursor', 'Droid', 'Devin', @@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([ 'Pi', 'PostHog', 'Qwen Code', - 'Repo', - 'Repos', 'Rovo Dev', - 'Commit', - 'Commits', 'Markdown', - 'Terminal', - 'Terminals', 'VS Code', 'Warp', 'Zed', - 'agent', - 'agents', 'android', 'codex', - 'commit', - 'commits', 'gemini', 'claude', 'markdown', - 'repo', - 'repos', - 'terminal', - 'terminals', 'gh', 'idle', 'anthropic', @@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) { return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value) } +function replaceMistranslatedForm(value, wrong, brand, locale) { + let result = '' + let cursor = 0 + for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) { + const end = at + wrong.length + result += value.slice(cursor, at) + result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand + cursor = end + } + return result + value.slice(cursor) +} + function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { let result = localeValue const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {} @@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (isScreenCursorContext(brand, enValue, key)) { continue } - if (isLocalizedProseTermContext(brand, key)) { - continue - } if (includesPreservedLatinTerm(result, brand)) { continue } @@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') { if (!result.includes(wrong)) { continue } + // Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it + // rewrote ~2000 translated values back to English on every repair run. + if (isCanonicalGenericRendering(brand, locale, wrong)) { + continue + } // Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand. if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) { continue } - result = result.replaceAll(wrong, brand) + result = replaceMistranslatedForm(result, wrong, brand, locale) } } diff --git a/config/scripts/locale-translation-policy.test.mjs b/config/scripts/locale-translation-policy.test.mjs index 1722f8f946ee..d420f70af8d3 100644 --- a/config/scripts/locale-translation-policy.test.mjs +++ b/config/scripts/locale-translation-policy.test.mjs @@ -80,7 +80,7 @@ describe('locale-translation-policy', () => { localeValue: '壊れた小切手に対して AI エージェントを開始しました。', locale: 'ja' }) - ).toBe('失敗したチェックに対して AI agent を開始しました。') + ).toBe('失敗したチェックに対して AI エージェントを開始しました。') expect( repairTranslatedValue({ key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94', @@ -88,7 +88,7 @@ describe('locale-translation-policy', () => { localeValue: '電話機からターミナルとエージェントを制御します。', locale: 'ja' }) - ).toBe('スマートフォンから terminals と agents を操作') + ).toBe('スマートフォンからターミナルとエージェントを操作') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.934add88b6', @@ -267,7 +267,7 @@ describe('locale-translation-policy', () => { localeValue: '未已检测代理', locale: 'zh' }) - ).toBe('未检测到 agents') + ).toBe('未检测到代理') expect( repairTranslatedValue({ key: 'auto.components.skills.SkillsPage.38e0951c3a', @@ -275,7 +275,7 @@ describe('locale-translation-policy', () => { localeValue: '代理技巧', locale: 'zh' }) - ).toBe('Agent 技能') + ).toBe('代理技能') expect( repairTranslatedValue({ key: 'auto.components.settings.appearance.search.9ae151b26b', @@ -358,7 +358,7 @@ describe('locale-translation-policy', () => { localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。', locale: 'ja' }) - ).toBe('commit するものはありません。PR はすでにマージされています。') + ).toBe('コミットするものはありません。PR はすでにマージされています。') expect( repairTranslatedValue({ key: 'auto.components.settings.integrations.search.581844769a', diff --git a/config/scripts/locale-translation-policy.zh-round5.test.mjs b/config/scripts/locale-translation-policy.zh-round5.test.mjs index 6e451c6d89b5..28901fba8d45 100644 --- a/config/scripts/locale-translation-policy.zh-round5.test.mjs +++ b/config/scripts/locale-translation-policy.zh-round5.test.mjs @@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('集成') }) - it('keeps Terminal as a product surface term', () => { + // Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical + // connector") mistranslation still reverts to Latin. + it('keeps terminal translated but reverts the 端子 mistranslation', () => { expect( repairTranslatedValue({ key: 'auto.components.settings.Settings.3de4bbb841', @@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端', locale: 'zh' }) - ).toBe('Terminal') + ).toBe('终端') expect( repairTranslatedValue({ key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab', @@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端 1', locale: 'zh' }) - ).toBe('Terminal 1') + ).toBe('终端 1') expect( repairTranslatedValue({ key: 'auto.components.agent.AgentCombobox.986f946354', @@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '分体式端子右', locale: 'zh' }) - ).toBe('向右拆分 Terminal') + ).toBe('向右拆分终端') expect( repairTranslatedValue({ key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019', @@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '终端Cursor', locale: 'zh' }) - ).toBe('Terminal Cursor') + ).toBe('终端 Cursor') expect( repairTranslatedValue({ key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9', @@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => { ).toBe('新 Terminal') }) - it('keeps workflow terms in English', () => { + // Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese. + it('keeps brand names English and generic workflow terms translated', () => { expect( repairTranslatedValue({ key: 'auto.components.sidebar.SidebarNav.9c95e1ce91', @@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '代理', locale: 'zh' }) - ).toBe('Agents') + ).toBe('代理') expect( repairTranslatedValue({ key: 'auto.components.GitHubItemDialog.28986b3747', @@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '已启动 AI 代理处理失败的检查。', locale: 'zh' }) - ).toBe('已启动 AI agent 处理失败的检查。') + ).toBe('已启动 AI 代理处理失败的检查。') expect( repairTranslatedValue({ key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef', @@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '次提交', locale: 'zh' }) - ).toBe('commits') + ).toBe('次提交') expect( repairTranslatedValue({ key: 'auto.store.slices.worktrees.d1d78a7baa', @@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => { 'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。', locale: 'zh' }) - ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。') + ).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。') }) it('does not confuse proxy copy with Agent terminology', () => { @@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => { localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹', locale: 'zh' }) - ).toBe('本地项目、Git repo 或包含多个 repos 的文件夹') + ).toBe('本地项目、Git 仓库或包含多个仓库的文件夹') }) it('keeps product, provider, code, and shell tokens untranslated', () => { diff --git a/config/scripts/locale-value-overrides.mjs b/config/scripts/locale-value-overrides.mjs index e237bca76b09..bde5d1233ea4 100644 --- a/config/scripts/locale-value-overrides.mjs +++ b/config/scripts/locale-value-overrides.mjs @@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = { 'OpenCode Go': 'OpenCode Go', 'Open in Cursor': 'Abrir en Cursor', 'Local project, Git repo, or folder with many repos': - 'Proyecto local, repo de Git o carpeta con muchos repos' + 'Proyecto local, repositorio de Git o carpeta con muchos repositorios' }, ko: { Save: '저장', diff --git a/config/scripts/locale-zh-value-overrides.mjs b/config/scripts/locale-zh-value-overrides.mjs index d11c747c1df0..0a76dd8ea3e6 100644 --- a/config/scripts/locale-zh-value-overrides.mjs +++ b/config/scripts/locale-zh-value-overrides.mjs @@ -201,8 +201,7 @@ export const ZH_VALUE_OVERRIDES = { '每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。', 'Show Linear in the Tasks source picker and sidebar shortcuts.': '在任务源选择器和侧边栏快捷方式中显示 Linear。', - 'Local project, Git repo, or folder with many repos': - '本地项目、Git repo 或包含多个 repos 的文件夹', + 'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹', 'Staged Changes': '已暂存的更改', Changes: '更改', 'Untracked Files': '未跟踪文件', @@ -210,8 +209,8 @@ export const ZH_VALUE_OVERRIDES = { 'Split Down': '向下拆分', 'Split Left': '向左拆分', 'Split Right': '向右拆分', - 'Split Terminal Down': '向下拆分 Terminal', - 'Split Terminal Right': '向右拆分 Terminal', + 'Split Terminal Down': '向下拆分终端', + 'Split Terminal Right': '向右拆分终端', 'Optional account switching for Claude while preserving shared chat context.': 'Claude 的可选账户切换,同时保留共享聊天上下文。', 'Countdown timer showing time until prompt cache expires (Claude agents).': diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 901c651798d0..d3e25d3fd2ca 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -6,6 +6,9 @@ import process from 'node:process' // TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. import ts from 'typescript-api' +import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs' +import { repairTranslatedValue } from './locale-translation-policy.mjs' + const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts']) const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets']) const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain']) @@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) { return changed } +// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms +// back to English, so drift only surfaces when someone regenerates the catalog. +export function collectGenericTermRegressions(enEntries, localeEntries, localeName) { + const renderings = canonicalGenericRenderings(localeName) + if (renderings.length === 0) { + return [] + } + + const regressions = [] + for (const [key, enValue] of enEntries) { + const localeValue = localeEntries.get(key) + if (typeof enValue !== 'string' || typeof localeValue !== 'string') { + continue + } + const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName }) + if (repaired === localeValue) { + continue + } + // Why: {{agent}} is an interpolation name, not English copy the reader sees. + const repairedCopy = repaired.replace(PLACEHOLDER_RE, '') + for (const { form, terms } of renderings) { + if (!localeValue.includes(form) || repaired.includes(form)) { + continue + } + if ( + terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy)) + ) { + regressions.push({ key, form, localeValue, repaired }) + break + } + } + } + return regressions +} + +function formatGenericTermRegressions(regressions) { + return regressions + .map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`) + .join('\n') +} + function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } = collectLocaleParityIssues(enCatalog, localeCatalog) + const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName) // Why: feature PRs own English declarations; absent target leaves deliberately // use i18next's existing English fallback until a localization PR supplies them. @@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { `${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.` ) - if (extraInLocale.length > 0 || interpolationMismatches.length > 0) { + if ( + extraInLocale.length > 0 || + interpolationMismatches.length > 0 || + genericTermRegressions.length > 0 + ) { console.error(`Locale catalog validation failed for ${localeName}.json.`) if (extraInLocale.length > 0) { console.error('') @@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) { console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`) } } + if (genericTermRegressions.length > 0) { + console.error('') + console.error( + 'repair-locale-catalog would rewrite these translated terms back to English.', + 'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs', + 'instead of listing its translation as a mistranslation.' + ) + console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20))) + if (genericTermRegressions.length > 20) { + console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`) + } + } return 1 } diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index 89e24bc0c0dd..4bf3d7d7ebad 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -4,7 +4,10 @@ import path from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs' +import { + collectGenericTermRegressions, + main as verifyLocalizationCatalog +} from './verify-localization-catalog.mjs' function writeJson(filePath, value) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8') @@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => { report.mockRestore() } }) + + // Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English. + it('flags catalog values the repair policy would rewrite back to English', () => { + const enEntries = new Map([['auto.example.commitLabel', 'Commit message']]) + + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de confirmación']]), + 'es' + ) + ).toEqual([]) + + // A locale whose committed value is the English term is stable, not a regression. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Commit']]), + 'es' + ) + ).toEqual([]) + + // 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected. + expect( + collectGenericTermRegressions( + enEntries, + new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]), + 'es' + ) + ).toEqual([]) + }) + + it('ignores interpolation names when looking for English rewrites', () => { + expect( + collectGenericTermRegressions( + new Map([ + ['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}'] + ]), + new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]), + 'zh' + ) + ).toEqual([]) + }) })