From 7b45e3244565e1c6d34cfcd75fab88c0dbf42bed Mon Sep 17 00:00:00 2001 From: heeeione Date: Sun, 6 Sep 2026 15:27:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(i18n-ko):=20core=20=E2=80=94=20add=20k?= =?UTF-8?q?o=20to=20UiLocale=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `ko` to UI_LOCALES so the closed locale vocabulary carries Korean: the `isUiLocale`/`isUiLocalePreference` guards accept it, and `resolveSystemUiLocale` recognizes the `ko` prefix through the existing case-insensitive, `_`-normalizing path (`ko`, `ko-KR`, `ko_KR`, `ko_KR.UTF-8`), so an `auto` preference resolves to it without being persisted. `uiLocaleToIntlLocale` stops being identity. Every locale so far was already the tag `Intl` wants; bare `ko` leaves the region open, and the region is what selects Korean date, number, and plural formatting, so it is widened to `ko-KR`. The return type becomes the literal union of the tags actually emitted — `'zh-CN' | 'zh-TW' | 'en' | 'ko-KR'` — rather than `string`, so the set stays visible in the signature. Every call site feeds an `Intl` constructor, `toLocaleString`, or `localeCompare`, all of which take `string`, so narrowing it is safe. The formatter test can no longer assert identity, so it pins the tag table instead and checks each tag is canonical, which keeps a locale added later from reaching `Intl` without a deliberate tag. Refs #3975 Generated-by: Claude Code --- packages/core/src/__tests__/ui-locale.test.ts | 51 +++++++++++++++++-- packages/core/src/ui-locale.ts | 19 +++++-- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/packages/core/src/__tests__/ui-locale.test.ts b/packages/core/src/__tests__/ui-locale.test.ts index 3304657925..cccfa3bf7e 100644 --- a/packages/core/src/__tests__/ui-locale.test.ts +++ b/packages/core/src/__tests__/ui-locale.test.ts @@ -34,14 +34,18 @@ import { describe('UI locale', () => { it('accepts only the supported resolved locales and preferences', () => { - assert.equal(['zh-CN', 'zh-TW', 'en'].every(isUiLocale), true); + assert.equal(['zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocale), true); assert.equal(isUiLocale('zh'), false); - assert.equal(['auto', 'zh-CN', 'zh-TW', 'en'].every(isUiLocalePreference), true); + assert.equal(isUiLocale('ko-KR'), false); + assert.equal(['auto', 'zh-CN', 'zh-TW', 'ko', 'en'].every(isUiLocalePreference), true); }); it('normalizes the legacy persisted preference without widening the locale contract', () => { assert.equal(normalizeUiLocalePreference('zh'), 'zh-CN'); assert.equal(normalizeUiLocalePreference('zh-TW'), 'zh-TW'); + assert.equal(normalizeUiLocalePreference('ko'), 'ko'); + assert.equal(normalizeUiLocalePreference('auto'), 'auto'); + assert.equal(normalizeUiLocalePreference('ko-KR'), 'auto'); assert.equal(normalizeUiLocalePreference('unsupported'), 'auto'); }); @@ -54,6 +58,17 @@ describe('UI locale', () => { [['zh-HK'], 'zh-TW'], [['zh_MO'], 'zh-TW'], [['zh_TW.UTF-8'], 'zh-TW'], + [['ko'], 'ko'], + [['ko-KR'], 'ko'], + [['ko_KR'], 'ko'], + [['KO-Kr'], 'ko'], + [['ko_KR.UTF-8'], 'ko'], + [['ko-Kore-KR'], 'ko'], + [['ko', 'ko-KR', 'en'], 'ko'], + [['en', 'ko'], 'en'], + [['ko', 'en'], 'ko'], + [['fr-FR', 'ko-KR'], 'ko'], + [['kok-IN'], 'en'], [['fr-FR', 'en-US'], 'en'], [[], 'en'], ] as const) { @@ -64,18 +79,41 @@ describe('UI locale', () => { it('resolves explicit preferences and overrides before the system locale', () => { assert.equal(resolveUiLocale('auto', 'zh-TW'), 'zh-TW'); + assert.equal(resolveUiLocale('auto', 'ko'), 'ko'); assert.equal(resolveUiLocale('zh-CN', 'zh-TW'), 'zh-CN'); + assert.equal(resolveUiLocale('ko', 'en'), 'ko'); + assert.equal(resolveUiLocale('en', 'ko'), 'en'); assert.equal(resolveUiLocale('zh-CN', 'zh-CN', 'en'), 'en'); + assert.equal(resolveUiLocale('auto', 'ko', 'zh-TW'), 'zh-TW'); }); it('keeps every locale guard and formatter in step with UI_LOCALES', () => { + // `ko` is the first locale whose Intl tag is not its own name, so this can + // no longer assert identity. Pinning the table keeps a locale added later + // from silently reaching `Intl` without a deliberate tag. + const intlTags: Record<(typeof UI_LOCALES)[number], string> = { + 'zh-CN': 'zh-CN', + 'zh-TW': 'zh-TW', + ko: 'ko-KR', + en: 'en', + }; for (const locale of UI_LOCALES) { assert.ok(isUiLocale(locale), locale); assert.equal(resolveSystemUiLocale([locale]), locale); - assert.equal(uiLocaleToIntlLocale(locale), locale); + assert.equal(uiLocaleToIntlLocale(locale), intlTags[locale]); } const intlLocales = UI_LOCALES.map(uiLocaleToIntlLocale); assert.equal(new Set(intlLocales).size, UI_LOCALES.length); + for (const tag of intlLocales) { + assert.equal(new Intl.Locale(tag).baseName, tag, tag); + } + }); + + it('maps ko onto the region-qualified Intl tag', () => { + assert.equal(uiLocaleToIntlLocale('ko'), 'ko-KR'); + assert.equal(uiLocaleToIntlLocale('en'), 'en'); + assert.equal(uiLocaleToIntlLocale('zh-CN'), 'zh-CN'); + assert.equal(uiLocaleToIntlLocale('zh-TW'), 'zh-TW'); }); }); @@ -87,12 +125,14 @@ describe('UI message catalogs', () => { }>()({ en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, 'zh-CN': { title: '状态', detail: { ready: '就绪' } }, + ko: { title: '상태', detail: { ready: '준비됨' } }, }); assert.deepEqual(resolveUiMessageCatalog(catalog), { en: { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, 'zh-CN': { title: '状态', detail: { ready: '就绪', waiting: 'Waiting' } }, 'zh-TW': { title: 'Status', detail: { ready: 'Ready', waiting: 'Waiting' } }, + ko: { title: '상태', detail: { ready: '준비됨', waiting: 'Waiting' } }, }); }); @@ -101,6 +141,11 @@ describe('UI message catalogs', () => { assert.equal(formatUiMessage(template, { count: 1 }, 'en'), '1 tool'); assert.equal(formatUiMessage(template, { count: 3 }, 'en'), '3 tools'); + + // Korean has one plural form; both counts take the `other` branch. + const koTemplate = '{count, plural, other {도구 #개}}'; + assert.equal(formatUiMessage(koTemplate, { count: 1 }, 'ko'), '도구 1개'); + assert.equal(formatUiMessage(koTemplate, { count: 3 }, 'ko'), '도구 3개'); }); it('fails soft for missing or inherited interpolation values', () => { diff --git a/packages/core/src/ui-locale.ts b/packages/core/src/ui-locale.ts index 2272506d79..ba3aceaad2 100644 --- a/packages/core/src/ui-locale.ts +++ b/packages/core/src/ui-locale.ts @@ -20,7 +20,7 @@ import { IntlMessageFormat } from 'intl-messageformat'; /** Resolved locales supported by human-facing Maka clients. */ -export const UI_LOCALES = ['zh-CN', 'zh-TW', 'en'] as const; +export const UI_LOCALES = ['zh-CN', 'zh-TW', 'ko', 'en'] as const; export type UiLocale = (typeof UI_LOCALES)[number]; @@ -120,7 +120,7 @@ function isMessageRecord(value: unknown): value is Readonly Date: Wed, 9 Sep 2026 13:51:00 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(i18n-ko):=20core=20=E2=80=94=20add=20k?= =?UTF-8?q?o=20to=20the=20three=20UI=20copy=20catalogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ko` joined `UI_LOCALES` in the previous commit, and `UiCatalog` is `Record`, so every catalog in the package became structurally incomplete at once. Three carried copy without a Korean branch: `GENERALIZED_ERROR_COPY`, `JUST_NOW`, and `STRINGS_BY_LOCALE`. Fill them in so `@maka/core` typechecks again. The generalized-error and just-now catalogs are direct translations. The quiet-preview strings follow the English branch rather than the Chinese one where the two disagree: `moreQuestions` reports the remainder past the previewed entry (`외 1개` for two questions) instead of the total the Chinese branches state, since that is how the count reads naturally in Korean. `bytes` and `replacements` likewise drop the `共`/`等` prefixes that have no Korean counterpart. `uiLocaleToIntlLocale` already widens `ko` to `ko-KR`, so the relative formatter picks up Korean units without further work; the test pins `1분 전` to keep that path honest. Tests mirror the existing per-locale coverage: a full classification table for Korean asserting Hangul output and no secret leakage, the shared cross-catalog routing assertions, the just-now and sidebar-unit cases, and the quiet-preview question-count and background-terminal lines. Verified with `npm run build:test` through `@maka/core`, the three suites under `packages/core/dist/__tests__` (54 passing), the full core suite (841 passing), and biome lint/format on the touched files. `@maka/core` typechecks clean. Downstream packages still carry catalogs without `ko` and are left to their own commits. Refs #3975 Generated-by: Claude Code --- packages/core/src/__tests__/redaction.test.ts | 64 +++++++++++++++++++ .../core/src/__tests__/relative-time.test.ts | 6 +- .../src/__tests__/tool-quiet-preview.test.ts | 15 +++++ packages/core/src/redaction.ts | 7 ++ packages/core/src/relative-time.ts | 1 + packages/core/src/tool-quiet-preview.ts | 10 +++ 6 files changed, 102 insertions(+), 1 deletion(-) diff --git a/packages/core/src/__tests__/redaction.test.ts b/packages/core/src/__tests__/redaction.test.ts index 10eaec614b..3faf43957b 100644 --- a/packages/core/src/__tests__/redaction.test.ts +++ b/packages/core/src/__tests__/redaction.test.ts @@ -363,6 +363,7 @@ describe('generalizedErrorMessageForLocale', () => { const timeout = new Error('request timeout after 30s'); assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'en'), 'Request timed out'); assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'zh-CN'), '请求超时'); + assert.equal(generalizedErrorMessageForLocale(timeout, 'fallback', 'ko'), '요청 시간 초과'); assert.equal( generalizedErrorMessageForLocale(new Error('unclassified'), '操作失败', 'zh-CN'), '操作失败', @@ -385,6 +386,10 @@ describe('generalizedErrorMessageForLocale', () => { generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'zh-TW'), '網路錯誤', ); + assert.equal( + generalizedErrorMessageForLocale(new Error(raw), 'fallback', 'ko'), + '네트워크 오류', + ); } }); }); @@ -480,6 +485,60 @@ describe('generalizedErrorMessageForLocale zh-CN', () => { }); }); +describe('generalizedErrorMessageForLocale ko', () => { + test('maps provider failures to Korean categories without leaking secrets', () => { + for (const [raw, expected] of [ + ['Request timeout after 30s', '요청 시간 초과'], + ['HTTP 429 Too Many Requests', '모델 속도 제한 초과'], + ['OpenAI rate limit reached for model gpt-4', '모델 속도 제한 초과'], + ['rate exceeded', '모델 속도 제한 초과'], + ['401 Unauthorized', '인증 실패'], + ['HTTP 403 forbidden', '인증 실패'], + ['Authentication failed', '인증 실패'], + ['HTTP 500 Internal Server Error', '모델 제공자 오류'], + ['Provider returned 503', '모델 제공자 오류'], + ['Bad gateway 502', '모델 제공자 오류'], + ['fetch failed', '네트워크 오류'], + ['ECONNREFUSED', '네트워크 오류'], + ['ENOTFOUND api.example.test', '네트워크 오류'], + ['network unreachable', '네트워크 오류'], + ['something weird happened', '작업에 실패했습니다'], + ['401 Authorization: Bearer sk-live-secret-token-value', '인증 실패'], + ]) { + const message = generalizedErrorMessageForLocale(new Error(raw), '작업에 실패했습니다', 'ko'); + assert.equal(message, expected); + assert.match(message, /[가-힣]/); + assert.doesNotMatch(message, /sk-live-secret-token-value/); + } + assert.equal( + generalizedErrorMessageForLocale('non-Error string input', '작업에 실패했습니다', 'ko'), + '작업에 실패했습니다', + ); + }); + + test('uses a caller-supplied Korean fallback for unknown errors', () => { + assert.equal( + generalizedErrorMessageForLocale( + new Error('something weird happened'), + '세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.', + 'ko', + ), + '세션은 생성되었지만 전송에 실패했습니다. 다시 시도해 주세요.', + ); + }); + + test('does not mistake runtime authority errors for authentication failures', () => { + assert.equal( + generalizedErrorMessageForLocale( + new Error('Conversation copy contains durable runtime authority facts'), + '이 컨텍스트로는 새 세션을 만들 수 없습니다.', + 'ko', + ), + '이 컨텍스트로는 새 세션을 만들 수 없습니다.', + ); + }); +}); + describe('localized generalized error messages', () => { test('routes one shared classification through each locale catalog', () => { const error = new Error('HTTP 503 from provider'); @@ -489,6 +548,7 @@ describe('localized generalized error messages', () => { ); assert.equal(generalizedErrorMessageForLocale(error, '後備', 'zh-CN'), '模型服务返回错误'); assert.equal(generalizedErrorMessageForLocale(error, '備援', 'zh-TW'), '模型服務傳回錯誤'); + assert.equal(generalizedErrorMessageForLocale(error, '대체 문구', 'ko'), '모델 제공자 오류'); }); test('uses Taiwan terminology for Traditional Chinese categories', () => { @@ -508,6 +568,10 @@ describe('localized generalized error messages', () => { const error = new Error('something weird happened'); assert.equal(generalizedErrorMessageForLocale(error, '简中后备', 'zh-CN'), '简中后备'); assert.equal(generalizedErrorMessageForLocale(error, '繁中備援', 'zh-TW'), '繁中備援'); + assert.equal( + generalizedErrorMessageForLocale(error, '한국어 대체 문구', 'ko'), + '한국어 대체 문구', + ); assert.equal( generalizedErrorMessageForLocale(error, 'English fallback', 'en'), 'English fallback', diff --git a/packages/core/src/__tests__/relative-time.test.ts b/packages/core/src/__tests__/relative-time.test.ts index 9cb6cc031d..99a70361e8 100644 --- a/packages/core/src/__tests__/relative-time.test.ts +++ b/packages/core/src/__tests__/relative-time.test.ts @@ -38,12 +38,16 @@ describe('relative timestamp labels', () => { for (const ageMs of [0, 1_000, 30_000, 59_999]) { assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); + assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전'); assert.equal(formatRelativeTimestamp(NOW - ageMs, NOW, 'en'), 'just now'); assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); + assert.equal(formatCompactTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전'); assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'zh-CN'), '刚刚'); + assert.equal(formatSidebarTimestamp(NOW - ageMs, NOW, 'ko'), '방금 전'); } assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'zh-CN'), '1分钟前'); + assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'ko'), '1분 전'); assert.equal(formatRelativeTimestamp(NOW - 60_000, NOW, 'en'), '1 minute ago'); }); @@ -54,7 +58,7 @@ describe('relative timestamp labels', () => { }); it('uses scan-friendly units for sidebar timestamps', () => { - for (const locale of ['zh-CN', 'en'] as const) { + for (const locale of ['zh-CN', 'ko', 'en'] as const) { for (const [ageMs, expected] of [ [60_000, '1min'], [46 * 60_000, '46min'], diff --git a/packages/core/src/__tests__/tool-quiet-preview.test.ts b/packages/core/src/__tests__/tool-quiet-preview.test.ts index 1404b67ad2..9008521fb5 100644 --- a/packages/core/src/__tests__/tool-quiet-preview.test.ts +++ b/packages/core/src/__tests__/tool-quiet-preview.test.ts @@ -79,6 +79,16 @@ describe('formatToolInvocationLine', () => { ), '選哪個方案? 等 2 問', ); + assert.equal( + formatToolInvocationLine( + { + toolName: 'AskUserQuestion', + args: { questions: [{ question: '어느 방안을 고를까요?' }, { question: '계속할까요?' }] }, + }, + 'ko', + ), + '어느 방안을 고를까요? 외 1개', + ); }); it('keeps the ScheduledTask title headline', () => { @@ -184,6 +194,11 @@ describe('projectToolArgsPreview', () => { assert.ok(line !== undefined); assert.match(line, /后台终端交互/); assert.match(line, /80x24/); + + const koLine = formatToolInvocationLine({ toolName: 'WriteStdin', args: preview }, 'ko'); + assert.ok(koLine !== undefined); + assert.match(koLine, /백그라운드 터미널 상호작용/); + assert.match(koLine, /80x24/); }); it('returns undefined when nothing displayable exists', () => { diff --git a/packages/core/src/redaction.ts b/packages/core/src/redaction.ts index 54c5644c6a..fb2e5d38f5 100644 --- a/packages/core/src/redaction.ts +++ b/packages/core/src/redaction.ts @@ -278,6 +278,13 @@ export const GENERALIZED_ERROR_COPY = { provider_error: 'Provider returned an error', network_error: 'Network error', }, + ko: { + timeout: '요청 시간 초과', + rate_limited: '모델 속도 제한 초과', + auth_failed: '인증 실패', + provider_error: '모델 제공자 오류', + network_error: '네트워크 오류', + }, } satisfies UiCatalog>; export function generalizedErrorMessageForLocale( diff --git a/packages/core/src/relative-time.ts b/packages/core/src/relative-time.ts index e2d41d4fdd..4d03e8f613 100644 --- a/packages/core/src/relative-time.ts +++ b/packages/core/src/relative-time.ts @@ -51,6 +51,7 @@ const JUST_NOW: UiCatalog = { 'zh-CN': '刚刚', 'zh-TW': '剛剛', en: 'just now', + ko: '방금 전', }; /** Future timestamps are treated as age zero and therefore display as just now. */ diff --git a/packages/core/src/tool-quiet-preview.ts b/packages/core/src/tool-quiet-preview.ts index 760d8990d4..46505563c8 100644 --- a/packages/core/src/tool-quiet-preview.ts +++ b/packages/core/src/tool-quiet-preview.ts @@ -82,6 +82,16 @@ const STRINGS_BY_LOCALE: Record = { bytes: (n) => `${n} bytes`, moreQuestions: (total) => (total > 1 ? ` +${total - 1} more` : ''), }, + ko: { + backgroundTerminal: '백그라운드 터미널 상호작용', + empty: '(비어 있음)', + done: '완료', + notDone: '미완료', + replacements: (n) => `${n}군데`, + written: '기록됨', + bytes: (n) => `${n}바이트`, + moreQuestions: (total) => (total > 1 ? ` 외 ${total - 1}개` : ''), + }, }; function strings(locale: UiLocale): QuietPreviewStrings {