From 9de06952ab68ccd4fd6e0ae6f04b57cc307e278a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Tue, 18 Aug 2026 12:18:03 +0800 Subject: [PATCH] fix(locale): persist an explicit pick of the provisional locale setLocale returned early when the id already matched the active locale, so choosing the language already on screen wrote nothing. That value may be a provisional browser-derived or fallback resolution nothing has stored, so a different browser sharing the DSH home still resolved on its own. Write unconditionally; keep the render publish conditional. Broaden the dictionary parity gate to every workspace package, pair zh/en across sibling files and inline registrations, and fail when a dictionary has no counterpart. It previously scanned only packages/client and packages/ extensions, compared within a single module, and silently skipped unpaired dictionaries -- so the split locales/zh.ts + en.ts common pair, the inline directory-picker-browse dictionary, and session-log-export were unchecked. Normalize paths at ingestion so the sweep does not narrow on Windows. Regenerate the client API catalog and update the locale README pair: both described the old zh fallback direction. Add the English fallback dialog golden, and drop a dead afterEach plus the blank lines left where the dead browser-language pins were removed. --- apps/web/tests/settings-chrome.e2e.ts | 9 +- .../settings-chrome/dialog-en.expected.md | 45 ++++ packages/client/locale/README.i18n.yaml | 4 +- packages/client/locale/README.md | 2 +- packages/client/locale/README.zh.md | 2 +- packages/client/locale/src/client/index.ts | 11 +- .../client/locale/tests/apply.client.spec.ts | 6 +- .../client/locale/tests/locale.client.spec.ts | 22 +- .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 1 - .../tests/apply.client.spec.ts | 2 - .../ui-theme/tests/apply.client.spec.ts | 1 - .../ui-workspace/tests/apply.client.spec.ts | 1 - .../src/client/api-catalog.ts | 2 +- scripts/locale-dictionary-parity.spec.ts | 243 +++++++++++++----- 17 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index f3cf4b3bbe..216dae4dbb 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -24,6 +24,8 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import.meta.url)) const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md') const PLUGINS_EXPECTED = join(SNAPSHOT_DIR, 'plugins.expected.md') +// The English fallback surface: a browser naming no shipped language. +const DIALOG_EN_EXPECTED = join(SNAPSHOT_DIR, 'dialog-en.expected.md') const PLUGIN_ROW_SELECTOR = '[data-plugin-entry$="ui-settings"]' const MODE = webSnapshotMode() @@ -496,6 +498,11 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) + // Golden of the English fallback dialog — the visible output this change + // produces. The zh golden above covers the detected-locale surface, so + // the pair pins both directions of the resolution. + const snapshot = await captureStableAria(frPage, '[role="dialog"]', fresh.workspaceCwd) + await compareOrRefreshGolden(DIALOG_EN_EXPECTED, snapshot, MODE) expect(frTripwire.pageErrors).toEqual([]) expect(frTripwire.warnings).toEqual([]) } finally { @@ -506,6 +513,6 @@ describe('web e2e: settings modal and General preferences', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['dialog.expected.md', 'plugins.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['dialog-en.expected.md', 'dialog.expected.md', 'plugins.expected.md']) }) }) diff --git a/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md new file mode 100644 index 0000000000..605e2fe328 --- /dev/null +++ b/apps/web/tests/snapshots/settings-chrome/dialog-en.expected.md @@ -0,0 +1,45 @@ +- dialog "Settings": + - navigation: + - text: Settings + - button "General": + - img + - text: General + - button "Models": + - img + - text: Models + - button "Plugins": + - img + - text: Plugins + - button "Agent presets": + - img + - text: Agent presets + - button "Open configuration file" + - button "Close": + - img + - text: Close + - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. + - button "Standard mode": + - text: Standard mode + - img + - text: Permission Choose the default permission mode for new sessions + - button "Workspace Write": + - text: Workspace Write + - img + - text: Language + - button "English": + - text: English + - img + - text: Appearance + - button "Light": + - img + - text: Light + - button "Dark": + - img + - text: Dark + - button "System" [pressed]: + - img + - text: System + - text: Enter behavior while busy Busy only; Cmd/Ctrl+Enter uses the other behavior + - button "Queue": + - text: Queue + - img diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index 126c4ee685..e1cc2a1c88 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: a63807f093dc12831a41196e61008151868e3205 -README.zh.md: b302e6055ba30d2db0948c0a6f1551c4a9dcb24c +README.md: 3fb5cce334e59b36c30f22a863f8e91d260f2ac9 +README.zh.md: 4f08344d6f030e408e570ff0ad31d0b0d4de3ecc diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index a63807f093..3fb5cce334 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `zh` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → zh → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +Locale plugin: LocaleRuntime — the `zh`/`en` preference stored as `locale.preference` in `$DSH_HOME/settings.yaml`; when that explicit Host value is absent, a fresh browser starts provisionally in the language `navigator` asks for (primary-subtag matching, with `en` when it asks for no language this app ships). The Host read runs after plugin activation so an unavailable settings service cannot block the page; its result replaces the provisional browser value live. Remote browsers retain only a process-local selection because the settings API is loopback-only. `locale/change` fires on switches, and the plugin points `` at the active locale (`zh-CN`/`en`) on activation and on every switch. The service also owns the ns×locale dictionary registry (typed `register(ns, {zh, en})` checked against `LocaleNamespaceMap`, `bind(ns)`→`TranslateNS`; lookup chain ns → common → en → key), implements the slot system's `LocaleFace`, and installs itself through `ctx.slots.installLocale`, backing the framework-injected `t` standard seat (`Translate`/`TranslateNS` are ui-slots types; import them from there — this package only re-exports for dictionary owners' convenience). The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. ## Model Experience diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index b302e6055b..4f08344d6f 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `zh`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → zh → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +locale 插件:LocaleRuntime——`zh`/`en` 偏好以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;若没有显式 Host 值,全新浏览器会暂时使用 `navigator` 请求的语言(按主子标签匹配;若其请求的语言本应用都不提供,则使用 `en`)。Host 读取在插件激活后执行,因此 settings 服务不可用不会阻塞页面;读取结果会实时替换浏览器暂定值。settings API 仅限回环请求,因此远程浏览器的选择仅保留在进程内。`locale/change` 仅在切换语言时触发;插件会在激活时以及每次切换时把 `` 指向当前 locale(`zh-CN`/`en`)。该服务还拥有 ns×locale 字典注册表(类型化 `register(ns, {zh, en})` 按 `LocaleNamespaceMap` 校验,`bind(ns)`→`TranslateNS`;查找链 ns → common → en → key),实现 slot 系统的 `LocaleFace`,并经 `ctx.slots.installLocale` 自行安装,支撑框架注入的 `t` 标准席位(`Translate`/`TranslateNS` 是 ui-slots 的类型;请从那里导入——本包的再导出仅为字典所有者提供便利)。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 ## 模型体验 diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index ab84f054ad..3f14acf216 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -197,13 +197,20 @@ export class LocaleRuntime { /** * Switch the active locale — the only user preference write entry. + * + * The durable write happens even when the id already matches the active + * locale, because the active value may be a provisional browser-derived or + * fallback resolution that nothing has stored yet. Picking the language + * already on screen is still an explicit choice, and it must survive a + * different browser sharing the same DSH home. Only the render notification + * is conditional: republishing an unchanged locale would churn every + * subscriber for nothing. * @param id - a registered locale id; unknown ids throw. */ setLocale(id: string): void { const match = this.snapshot.locales.find(l => l.id === id) if (match === undefined) throw new Error(`locale "${id}" is not registered`) - if (this.snapshot.active === match.id) return - this.publish(match.id, true) + if (this.snapshot.active !== match.id) this.publish(match.id, true) void this.host?.set(LOCALE_PREFERENCE_FIELD, match.id) } diff --git a/packages/client/locale/tests/apply.client.spec.ts b/packages/client/locale/tests/apply.client.spec.ts index a5c70ed624..4f4d08a951 100644 --- a/packages/client/locale/tests/apply.client.spec.ts +++ b/packages/client/locale/tests/apply.client.spec.ts @@ -2,7 +2,7 @@ * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ import { Context } from '@deepseek-ai/cordis' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsScopeBinder } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-scope.ts' @@ -78,10 +78,6 @@ describe('locale apply', () => { // localized copy sets its locale explicitly via setLocale/Host preference // rather than leaning on FALLBACK_LOCALE. This file has no jsdom environment, // so there is no `window` and no browser-language detection to stub. - afterEach(() => { - vi.unstubAllGlobals() - }) - it('declares the slot service', () => { expect(inject).toEqual(['slots', 'connection', 'remote', 'settingsScope']) }) diff --git a/packages/client/locale/tests/locale.client.spec.ts b/packages/client/locale/tests/locale.client.spec.ts index eb279f1295..a945ccd07e 100644 --- a/packages/client/locale/tests/locale.client.spec.ts +++ b/packages/client/locale/tests/locale.client.spec.ts @@ -138,7 +138,7 @@ describe('LocaleRuntime', () => { expect(svc.getSnapshot().revision).toBe(before + 1) }) - it('setLocale writes through the scope, republishes an immutable snapshot, and no-ops on same value', () => { + it('setLocale writes through the scope and republishes only on a real change', () => { const host = stubSettingsScope() const { svc, events } = make(host) svc.setLocale('en') @@ -147,9 +147,27 @@ describe('LocaleRuntime', () => { expect(events).toHaveLength(1) expect(events[0]).toBe(svc.getLocale()) expect(events[0]!.revision).toBe(1) + // Re-selecting the active locale publishes nothing (no subscriber churn) + // but still writes: the active value may be a provisional browser-derived + // resolution nothing has stored, and picking it is an explicit choice that + // must outlive this browser. svc.setLocale('en') expect(events).toHaveLength(1) - expect(host.set).toHaveBeenCalledOnce() + expect(host.set).toHaveBeenCalledTimes(2) + expect(host.set).toHaveBeenLastCalledWith('preference', 'en') + }) + + it('persists an explicit pick of the provisional locale, so a shared DSH home agrees', () => { + // A browser naming no shipped language opens at FALLBACK_LOCALE with + // nothing stored. Choosing that same language in the menu must become + // durable, or a Chinese browser sharing the home still opens Chinese. + stubLanguages('fr-FR') + const host = stubSettingsScope() + const { svc } = make(host) + expect(svc.getLocale().active).toBe('en') + expect(host.set).not.toHaveBeenCalled() + svc.setLocale('en') + expect(host.set).toHaveBeenCalledWith('preference', 'en') }) it('setLocale without a host scope stays process-local', () => { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 7d998c7e11..e0d5c69e4e 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -21,7 +21,6 @@ import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSectio import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from '../src/client/AgentPresetSeat.tsx' - const ROSTER_ONE = { rpcId: 'r', result: { diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index f5d04fcfd6..e66fb73262 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -12,7 +12,6 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject, InputTriggerService } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { MenuViewInjected } from '@deepseek-ai/dsh-client-ui-input-trigger/client' - const sid = (k: string): SessionId => k as SessionId async function bench() { diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 79d597f9bc..f8c7407e5a 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -10,7 +10,6 @@ import { GeneralSection } from '../src/client/GeneralSection.tsx' import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx' import type { SettingsDocumentActionInjected } from '../src/client/SettingsDocumentAction.tsx' - /** The seats this plugin fills for a loopback browser (slot name → expected component). */ const SEATS = [ ['settings.trigger', TriggerContent], diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index d73950a6f7..385f726d36 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -11,7 +11,6 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' - async function bench(isLoopback = true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index d8022887fa..c3987e38a5 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -13,7 +13,6 @@ import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' - /** * @param served - namespaces the Host describes; omitted answers a failed read, * which is what most of these specs want (no card has anything to render). @@ -87,7 +86,6 @@ describe('ui-settings-plugins apply', () => { expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'keyed', scope: 'root' }) }) - it('injects a live tab projection, the card directory, and one business face per card', async () => { const { ctx, slots } = await bench() declareRoot(slots) diff --git a/packages/client/ui-theme/tests/apply.client.spec.ts b/packages/client/ui-theme/tests/apply.client.spec.ts index 0ac3dc32b8..fa20e0dd3b 100644 --- a/packages/client/ui-theme/tests/apply.client.spec.ts +++ b/packages/client/ui-theme/tests/apply.client.spec.ts @@ -14,7 +14,6 @@ import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from '../src/theme-sett import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { createAppearanceRowStore } from '../src/client/settings-store.ts' - const SLOT = 'settings.general.item' function deferred() { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 4a819c8587..abba4371c2 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -7,7 +7,6 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' - async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 58bbbdee3f..c6539aa46a 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -106,7 +106,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'locale', summary: 'Dictionary registry plus locale preference.', - description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s zh fallback -> the shared common namespace (active, then zh) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', + description: 'Dictionary registry plus locale preference. Lookup chain per key: the entry\'s namespace in the active locale -> that namespace\'s en fallback -> the shared common namespace (active, then en) -> the key itself (missing text stays visible, fail loud in the UI rather than blank). Reads go through getLocale; writes only through setLocale; continuous sync through the `locale/change` event, or through the LocaleFace getSnapshot/subscribe pair the render machinery consumes (installed via `ctx.slots.installLocale`).', methods: [ { signature: 'getLocale(): LocaleSnapshot', diff --git a/scripts/locale-dictionary-parity.spec.ts b/scripts/locale-dictionary-parity.spec.ts index d48fc3e808..ea40919fd9 100644 --- a/scripts/locale-dictionary-parity.spec.ts +++ b/scripts/locale-dictionary-parity.spec.ts @@ -9,83 +9,144 @@ * only one side breaks that: a reader of the other language sees a bare key * such as `list.aria` instead of text. This gate fails on the asymmetry rather * than waiting for the bare key to reach a UI. + * + * Discovery is deliberately broad, because a gate that silently narrows is + * worse than no gate. It sweeps every workspace package (not just + * `packages/client`), reads dictionaries wherever they are declared — + * `locales.ts`, a `locales/` directory, or inline in the plugin body — and + * pairs `zh`/`en` across sibling files as well as within one module. A `zh` + * dictionary whose `en` counterpart cannot be found anywhere is an error, not + * a skip. */ import type { Dirent } from 'node:fs' -import { readdirSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' import { describe, expect, it } from 'vitest' const root = fileURLToPath(new URL('..', import.meta.url)) -/** Every `locales*.ts` module under a client package's `src/`. */ -function dictionaryModules(): string[] { +/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */ +function relative(file: string): string { + return file.slice(root.length).replaceAll('\\', '/') +} + +/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */ +function sourceFiles(): string[] { const files: string[] = [] - for (const group of ['client', 'extensions']) { - const groupRoot = resolve(root, 'packages', group) - let packages: string[] - try { - packages = readdirSync(groupRoot, { withFileTypes: true }) - .filter(entry => entry.isDirectory()) - .map(entry => entry.name) - } catch { - continue - } - for (const pkg of packages) { - const srcRoot = resolve(groupRoot, pkg, 'src') - walk(srcRoot, files) + const packagesRoot = resolve(root, 'packages') + for (const group of directories(packagesRoot)) { + for (const pkg of directories(resolve(packagesRoot, group))) { + walk(resolve(packagesRoot, group, pkg, 'src'), files) } } return files.sort() } -function walk(dir: string, out: string[]): void { +/** Immediate subdirectory names, or none when the path is not a directory. */ +function directories(dir: string): string[] { + if (!existsSync(dir)) return [] let entries: Dirent[] try { entries = readdirSync(dir, { withFileTypes: true }) } catch { + // Swallows only the race between existsSync and readdirSync (a package + // directory removed mid-sweep); readdirSync is the sole statement in the + // try, so no other failure can reach here. + return [] + } + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name) +} + +function walk(dir: string, out: string[]): void { + if (!existsSync(dir)) return + let entries: Dirent[] + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + // Same narrow race as `directories`: readdirSync is the only statement + // guarded, so this cannot mask a parse or assertion failure. return } for (const entry of entries) { const full = resolve(dir, entry.name) - if (entry.isDirectory()) { - walk(full, out) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { - if (/^locales?(\.[\w-]+)?\.ts$/.test(entry.name) || dir.endsWith('/locales')) out.push(full) - } + if (entry.isDirectory()) walk(full, out) + else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full) } } +/** One discovered dictionary: which file and export name declared it. */ +interface Dictionary { + /** Repo-relative declaring file. */ + file: string + /** Export name, or the registration site for an inline literal. */ + name: string + /** Declared keys, sorted. */ + keys: string[] +} + /** - * Keys of every top-level `export const ...= { ... }` object literal, - * read from the AST so the gate never executes package code. - * @param file - absolute path of the dictionary module. - * @returns exported dictionary name mapped to its declared keys. + * Keys of every top-level `export const = { ... }` object literal whose + * name identifies a locale dictionary, plus inline `register(ns, locale, {...})` + * literals. Read from the AST so the gate never executes package code. + * @param file - absolute path of a candidate module. + * @returns discovered dictionaries, keyed by locale-bearing name. */ -function exportedDictionaries(file: string): Map { - const source = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, true) - const found = new Map() +function dictionariesIn(file: string): Dictionary[] { + const text = readFileSync(file, 'utf8') + // Cheap pre-filter: parsing every package source is wasteful, and a file + // with no locale token cannot declare a dictionary under any shape below. + if (!/\b(zh|en)\b/.test(text)) return [] + const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true) + const found: Dictionary[] = [] + const rel = relative(file) + for (const statement of source.statements) { if (!ts.isVariableStatement(statement)) continue - const exported = statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) === true - if (!exported) continue + if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue for (const decl of statement.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue - const initializer = unwrap(decl.initializer) - if (initializer === undefined || !ts.isObjectLiteralExpression(initializer)) continue - const keys: string[] = [] - for (const prop of initializer.properties) { - if (!ts.isPropertyAssignment(prop)) continue - if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) - } - found.set(decl.name.text, keys.sort()) + const literal = unwrap(decl.initializer) + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (localeOf(decl.name.text) === undefined) continue + found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) }) } } + + // Inline registrations: a `[['zh', {...}], ['en', {...}]]` pair handed to a + // registration loop in the plugin body. Both halves key off the enclosing + // array's line so they pair with each other and not across sites. + const visit = (node: ts.Node): void => { + if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) { + const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + for (const element of node.elements) { + if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue + const [tag, dict] = element.elements + const literal = unwrap(dict) + if (tag === undefined || !ts.isStringLiteral(tag)) continue + if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue + if (tag.text !== 'zh' && tag.text !== 'en') continue + found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) }) + } + } + ts.forEachChild(node, visit) + } + visit(source) return found } +/** Declared property names of an object literal, sorted. */ +function keysOf(literal: ts.ObjectLiteralExpression): string[] { + const keys: string[] = [] + for (const prop of literal.properties) { + if (!ts.isPropertyAssignment(prop)) continue + if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) keys.push(prop.name.text) + } + return keys.sort() +} + /** Look through `satisfies`/`as`/parenthesized wrappers to the literal. */ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { let current = node @@ -98,40 +159,90 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined { return current } -/** Pair a `zh` export with the `en` export covering the same namespace. */ -function counterpart(name: string): string | undefined { - if (name === 'zh') return 'en' - if (name.startsWith('zh') && name.length > 2) return `en${name.slice(2)}` - if (name.endsWith('Zh')) return `${name.slice(0, -2)}En` +/** + * The locale a dictionary name declares, and the namespace-ish remainder that + * identifies which pair it belongs to. `zh`/`en`, `zhSettings`/`enSettings`, + * and `settingsZh`/`settingsEn` are the shapes this repo uses. + * @param name - export name or synthetic inline name. + * @returns locale plus pair key, or undefined when the name names no locale. + */ +function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined { + for (const locale of ['zh', 'en'] as const) { + const other = locale === 'zh' ? 'Zh' : 'En' + if (name === locale) return { locale, pair: '' } + if (name.startsWith(`${locale}@inline:`)) return { locale, pair: name.slice(name.indexOf(':')) } + if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) { + return { locale, pair: name.slice(2) } + } + if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) } + } return undefined } describe('shipped locale dictionaries', () => { it('declares the same keys in zh and en, so the single fallback locale always resolves', () => { - const modules = dictionaryModules() - // Guard the discovery itself: an empty sweep would pass every assertion - // below while checking nothing. - expect(modules.length).toBeGreaterThan(20) + const files = sourceFiles() + // Guard the discovery itself: an empty or narrowed sweep would pass every + // assertion below while checking nothing. + expect(files.length).toBeGreaterThan(500) - const mismatches: string[] = [] - let comparedPairs = 0 - for (const file of modules) { - const dicts = exportedDictionaries(file) - for (const [name, zhKeys] of dicts) { - const enName = counterpart(name) - if (enName === undefined) continue - const enKeys = dicts.get(enName) - if (enKeys === undefined) continue - comparedPairs++ - const rel = file.slice(root.length) - const zhOnly = zhKeys.filter(key => !enKeys.includes(key)) - const enOnly = enKeys.filter(key => !zhKeys.includes(key)) - if (zhOnly.length > 0) mismatches.push(`${rel} ${name} has keys absent from ${enName}: ${zhOnly.join(', ')}`) - if (enOnly.length > 0) mismatches.push(`${rel} ${enName} has keys absent from ${name}: ${enOnly.join(', ')}`) + // Pair within a file first; a dictionary whose counterpart is not in the + // same module then pairs with a sibling in the same directory. Both shapes + // ship here: `locales/settings.ts` exports zh+en together, while + // `locales/zh.ts` + `locales/en.ts` split the common pair across files. + const perFile = new Map() + for (const file of files) { + const dicts = dictionariesIn(file) + if (dicts.length > 0) perFile.set(relative(file), dicts) + } + + const groups = new Map>() + const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => { + const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>() + if (slot.has(locale)) { + throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`) + } + slot.set(locale, dict) + groups.set(key, slot) + } + + for (const [rel, dicts] of perFile) { + for (const dict of dicts) { + const parsed = localeOf(dict.name) + if (parsed === undefined) continue + const sameFileCounterpart = dicts.some((other) => { + const otherParsed = localeOf(other.name) + return otherParsed !== undefined + && otherParsed.pair === parsed.pair + && otherParsed.locale !== parsed.locale + }) + // Same-file pairs key by file so two pairs in one directory stay + // distinct; split pairs key by directory so siblings meet. + const key = sameFileCounterpart ? `${rel}::${parsed.pair}` : `${dirname(rel)}::${parsed.pair}` + place(key, parsed.locale, dict) } } - expect(comparedPairs).toBeGreaterThan(20) - expect(mismatches).toEqual([]) + const problems: string[] = [] + let comparedPairs = 0 + for (const [key, slot] of [...groups].sort()) { + const zh = slot.get('zh') + const en = slot.get('en') + if (zh === undefined || en === undefined) { + const present = zh ?? en + problems.push(`${present?.file} declares ${present?.name} with no counterpart for pair ${key}`) + continue + } + comparedPairs++ + const zhOnly = zh.keys.filter(k => !en.keys.includes(k)) + const enOnly = en.keys.filter(k => !zh.keys.includes(k)) + if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`) + if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`) + } + + // The shipped dictionary count only grows; a collapse means discovery or + // pairing broke, which would hide real asymmetry. + expect(comparedPairs).toBeGreaterThan(25) + expect(problems).toEqual([]) }) })