Bläddra i källkod

fix(locale): tighten parity gate and correct copy-source wording

Address the three open review threads on the dictionary parity gate.

Regex/TEXT: localeOf now requires an uppercase ASCII [A-Z] flat-letter at
the third position of a name-prefix shape, so zh2Foo/zh_probe are no longer
treated as dictionaries in localeOf while the admission pre-filter skips
them. The two now agree exactly.

register detection now also admits a bare register identifier callee in
addition to a property access, covering a future destructured
register(NS, 'zh'|'en', dict) call instead of silently dropping it.

A 3-arg register whose dictionary argument is a local variable is resolved
through module-scope const initializers; one that cannot be resolved to an
object literal makes the gate refuse with a named error rather than skipping
the registration and narrowing the sweep.

Also restate the FALLBACK_LOCALE rationale: the residual case points at
English because a browser naming neither shipped language is the reader
least likely to read Chinese, not because English is the copy's source
language (Chinese is; packages/client/AGENTS.md). Sync the identical claim
in the bilingual Agent Note and re-record its .i18n.yaml pairing.
Chinesezjc 1 månad sedan
förälder
incheckning
9301def7eb

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.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 .agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md
-2026-07-31-browser-derived-initial-locale.md: 6fcd799b9c3e6ec898725e0ef72106b63f613bee
-2026-07-31-browser-derived-initial-locale.zh.md: 73f2c825e11bb0380fe172b4b4522295d419e9a5
+2026-07-31-browser-derived-initial-locale.md: 66fd56327aeb4463bfb8f6426ce7f7962d339782
+2026-07-31-browser-derived-initial-locale.zh.md: 721a785aa476951e7254c50230ddc092b9f8b211

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.md

@@ -14,7 +14,7 @@ Reading the browser fixed the readers whose browser names a language this app sh
 
 **The provisional locale resolves through the browser, then `FALLBACK_LOCALE` (`en`); an explicit Host preference replaces it live.** `resolveInitialLocale()` in `packages/client/locale/src/client/index.ts` runs at service construction and expresses the browser/fallback order. The nonblocking settings lifecycle then applies optional `locale.preference` from `$DSH_HOME/settings.yaml`; absence leaves the browser-derived value active.
 
-**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`, the source language of the copy. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI.
+**One constant serves both the opening locale and the dictionary fallback, because the dictionaries are symmetric.** `FALLBACK_LOCALE` answers both "which language does the UI open in when the browser names none we ship" and "which dictionary backs a key the active locale misses". Those are different questions, and splitting them into two constants would be right if either answer had to differ — but every shipped `zh`/`en` pair declares identical key sets, so the fallback step always resolves and both answers are `en`. The residual case points at English rather than zh because a browser naming neither shipped language is the reader least likely to read Chinese. `scripts/locale-dictionary-parity.spec.ts` gates the symmetry the shared constant depends on: a key added to one side only fails that spec by name, instead of surfacing later as a bare key such as `list.aria` in a running UI.
 
 **Browser matching is on the primary subtag, over the ordered list.** `detectBrowserLocale()` walks `[...(navigator.languages ?? []), navigator.language]` and returns the first entry whose primary subtag names a shipped locale, so `zh-Hans-CN` and `zh-TW` both land on `zh` and `en-GB` on `en`, while a browser asking only for languages this app does not ship (`fr`, `de`) yields nothing and leaves `FALLBACK_LOCALE` in charge. `navigator.language` trails the list and covers its absence on hosts that ship a Navigator without `languages` — the DOM lib types it as always present, so that tolerance carries a narrow lint exception, the same environment-boundary distrust the `localStorage` guards already express.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-07-31-browser-derived-initial-locale.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 **暂定 locale 先经浏览器、再经 `FALLBACK_LOCALE`(`en`)解析;显式 Host 偏好会实时替换它。** `packages/client/locale/src/client/index.ts` 中的 `resolveInitialLocale()` 在服务构造时运行,并表达浏览器/回落顺序。随后,非阻塞 settings 生命周期会应用 `$DSH_HOME/settings.yaml` 中可选的 `locale.preference`;若该值缺失,则继续使用由浏览器派生的值。
 
-**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`,也就是文案的源语言。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
+**开场 locale 与字典回落值共用一个常量,因为两侧字典是对称的。** `FALLBACK_LOCALE` 同时回答「浏览器未声明任何本应用提供的语言时,界面以哪种语言开场」与「当前 locale 的字典缺失某个 key 时由哪本字典兜住」。这是两个不同的问题,若其中任一答案必须不同,拆成两个常量才是对的——但每一对已提供的 `zh`/`en` 字典都声明了完全相同的 key 集合,因此回落这一步总能解析成功,两个答案都是 `en`。残余情形指向英文而非 `zh`,是因为一个声明了本应用都不支持的语言的浏览器,其读者最不可能读中文。`scripts/locale-dictionary-parity.spec.ts` 为这个共用常量所依赖的对称性设了门禁:只加在一侧的 key 会让该用例指名失败,而不是日后在运行中的界面里显现为形如 `list.aria` 的裸 key。
 
 **浏览器匹配按主子标签进行,且遍历有序列表。** `detectBrowserLocale()` 遍历 `[...(navigator.languages ?? []), navigator.language]`,返回主子标签命中已提供 locale 的首个条目,因此 `zh-Hans-CN` 与 `zh-TW` 同归 `zh`、`en-GB` 归 `en`;而只请求本应用不提供的语言(`fr`、`de`)的浏览器则什么都匹配不到,交由 `FALLBACK_LOCALE` 接管。`navigator.language` 排在列表之后,并兜住那些 Navigator 上没有 `languages` 的宿主——DOM 库把它标注为必然存在,所以这份容忍带一条窄口径 lint 例外,与 `localStorage` 守卫表达的环境边界不信任同源。
 

+ 3 - 1
packages/client/locale/src/client/index.ts

@@ -91,7 +91,9 @@ declare module '@deepseek-ai/cordis' {
  * language (and for non-browser runs), and the dictionary consulted after the
  * active locale misses a key. One constant serves both because the shipped
  * `zh`/`en` dictionaries carry identical key sets, so neither direction can
- * leave a key unresolved; English is the source language of the copy.
+ * leave a key unresolved; the residual case points at English rather than
+ * zh because a browser naming neither shipped language is the reader least
+ * likely to read Chinese.
  */
 export const FALLBACK_LOCALE: LocaleId = 'en'
 

+ 52 - 13
scripts/locale-dictionary-parity.spec.ts

@@ -103,6 +103,20 @@ function dictionariesIn(file: string): Dictionary[] {
   const found: Dictionary[] = []
   const rel = relative(file)
 
+  // Module-scope variable declarations, keyed by name. A 3-arg
+  // `register(NS, 'zh'|'en', dict)` whose third argument is an identifier —
+  // e.g. a local dictionary variable rather than an inline literal — resolves
+  // through here so the gate still verifies its symmetry.
+  const moduleConsts = new Map<string, ts.Expression>()
+  for (const statement of source.statements) {
+    if (!ts.isVariableStatement(statement)) continue
+    for (const decl of statement.declarationList.declarations) {
+      if (ts.isIdentifier(decl.name) && decl.initializer !== undefined) {
+        moduleConsts.set(decl.name.text, decl.initializer)
+      }
+    }
+  }
+
   for (const statement of source.statements) {
     if (!ts.isVariableStatement(statement)) continue
     if (statement.modifiers?.some(m => m.kind === ts.SyntaxKind.ExportKeyword) !== true) continue
@@ -115,6 +129,14 @@ function dictionariesIn(file: string): Dictionary[] {
     }
   }
 
+  // A 3-arg `register(ns, 'zh'|'en', dict)` call whose dictionary argument we
+  // cannot turn into an object literal. We refuse instead of skipping: a
+  // registration we cannot measure is exactly the silent narrowing this gate
+  // exists to catch.
+  const refuse = (ns: string, tag: string, why: string): never => {
+    throw new Error(`cannot verify register('${ns}', '${tag}', ...) in ${rel}: ${why}`)
+  }
+
   // Inline registrations, two shapes. A `[['zh', {...}], ['en', {...}]]` pair
   // handed to a registration loop keys off the enclosing array; separate
   // `register(NS, 'zh', {...})` / `register(NS, 'en', {...})` calls key off the
@@ -122,20 +144,34 @@ function dictionariesIn(file: string): Dictionary[] {
   const visit = (node: ts.Node): void => {
     if (ts.isCallExpression(node)) {
       const callee = node.expression
-      const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined
+      const name = ts.isPropertyAccessExpression(callee)
+        ? callee.name.text
+        : ts.isIdentifier(callee) && callee.text === 'register' ? 'register' : undefined
       if (name === 'register' && node.arguments.length >= 3) {
         const [ns, tag, dict] = node.arguments
-        const literal = unwrap(dict)
-        if (
-          ns !== undefined && tag !== undefined && ts.isStringLiteral(tag)
-          && (tag.text === 'zh' || tag.text === 'en')
-          && literal !== undefined && ts.isObjectLiteralExpression(literal)
-        ) {
-          // The namespace expression's source text identifies the pair, so the
-          // zh and en calls for one namespace meet and calls for different
-          // namespaces stay apart.
-          found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(literal) })
+        if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return
+        if (tag.text !== 'zh' && tag.text !== 'en') return
+        const raw = unwrap(dict)
+        const literal = raw !== undefined && ts.isIdentifier(raw)
+          ? (() => {
+            const resolved = moduleConsts.get(raw.text)
+            return resolved === undefined ? undefined : unwrap(resolved)
+          })()
+          : raw
+        const why = raw !== undefined && ts.isIdentifier(raw)
+          ? `third argument ${raw.text} does not resolve to an inline or module-scope object literal`
+          : 'third argument is neither an object literal nor a resolvable dictionary variable'
+        if (literal === undefined || !ts.isObjectLiteralExpression(literal)) {
+          // The dictionary argument must resolve to an object literal; the
+          // gate refuses rather than skips, so the symmetry it verifies never
+          // silently narrows.
+          refuse(ns.getText(source), tag.text, why)
         }
+        const dictionary: ts.ObjectLiteralExpression = literal as ts.ObjectLiteralExpression
+        // The namespace expression's source text identifies the pair, so the
+        // zh and en calls for one namespace meet and calls for different
+        // namespaces stay apart.
+        found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) })
       }
     }
     if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
@@ -181,7 +217,10 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
 /**
  * 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.
+ * and `settingsZh`/`settingsEn` are the shapes this repo uses. A name-prefix
+ * shape requires an uppercase ASCII letter at the third position (`[A-Z]`),
+ * matching the admission of the cheap pre-filter, so `zh2Foo`/`zh_probe`
+ * cannot be treated as dictionaries in one place and skipped in another.
  * @param name - export name or synthetic inline name.
  * @returns locale plus pair key, or undefined when the name names no locale.
  */
@@ -192,7 +231,7 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin
     // Synthetic names for inline shapes carry their own pair key after the
     // first ':' (the enclosing array's line, or the namespace expression).
     if (name.startsWith(`${locale}@`)) return { locale, pair: name.slice(name.indexOf(':')) }
-    if (name.startsWith(locale) && name.length > 2 && name[2] === name[2]?.toUpperCase()) {
+    if (name.startsWith(locale) && name.length > 2 && /[A-Z]/.test(name[2] ?? '')) {
       return { locale, pair: name.slice(2) }
     }
     if (name.endsWith(other)) return { locale, pair: name.slice(0, -2) }