Skip to content

test(i18n): 占位符拼写门 —— 无 provider 回退只会遇到它认得的 {{name}} 拼形 (#3512) - #4904

Merged
yinlianghui merged 2 commits into
mainfrom
claude/issue-3512-placeholder-gate
Aug 17, 2026
Merged

test(i18n): 占位符拼写门 —— 无 provider 回退只会遇到它认得的 {{name}} 拼形 (#3512)#4904
yinlianghui merged 2 commits into
mainfrom
claude/issue-3512-placeholder-gate

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3512

按 2026-08-17 晋级评论重切后的范围实施:不拓宽 fallback,建占位符拼写门。行为本体 packages/i18n/src/useSafeTranslation.ts 一个字节都没改。

分叉与裁定

createSafeTranslationfallbackT 用精确字面 needle 插值(value.split(`{{${k}}}`).join(String(v))),所以它只认 {{name}} 一种拼形;而挂了 I18nProvider 时服务同一批文案的 i18next 还认 {{ name }}(花括号内空格)、{{count, number}}(格式化参数)、{{- name}}(反转义前缀)和 $t(nested)(嵌套)。四种里任何一种写进文案,挂 provider 渲染正确、无 provider 就把花括号原样吐给用户 —— 不抛错、不打日志,而且专挑独立/嵌入式宿主,正是我们最看不到的运行环境。

维护者 #4135 裁定(2026-08-11)已经把方向定死:

{{x}}(双花括号)专属 i18next 洞 —— 调用点写了 {{x}}必须传参。t() 下游填充的洞(组件侧替换)一律{x}(单花括号),它落在 i18next 语法之外,因此天然安全。

所以剩下的活不是教 fallback 再学三种方言(那等于长期维护第二个插值器去追 i18next),而是把文案约束到两条路径都认的那一种拼形上。declared = enforced:分叉从「靠运气不发生」变成「按构造不可达」。

这一条不是在重开 #3418 的裁定。#3418 是真实现 bug(replace 只替第一次,与 i18next 不一致),在生产端修掉了;这一条是声明一个受支持子集,#3512 卡面本身就把两者的性质区分开了,晋级评论按 #4135 裁定拍板。

会被字面 needle 消费的文案源清单(在 97da1b0 上实测)

# 规模 发现方式
1 十个语言包 builtInLocales 10 包 / 28,867 条字符串叶子 直接 import 数据结构
2 createSafeTranslation / createSafeTranslationHook 默认表 31 张 / 762 条字符串行 AST:工厂第一实参(内联字面量 / 同文件 const / 相对 import 的 const)
3 手写同款 needle 的兄弟表 3 张 注册表 + needle 文件集完备性钉

第 2 面按包展开:components 10 张(filter-builder 41 行、form 23、data-table 20、sort-builder 7、fullscreen-editor 6、navigation-overlay 6、config-panel-renderer 2、containers 2、close-label 1、action-param-dialog 1)、plugin-designer 1(164 行)、plugin-detail 1(148)、fields 1(75)、plugin-dashboard 2(75+6)、plugin-list 2(62+9)、collaboration 1(36)、plugin-grid 2(18+11)、plugin-timeline 1(16)、plugin-form 4、plugin-kanban 3、plugin-calendar 1、plugin-view 1、plugin-tree 1。

第 3 面是 GANTT_DEFAULT_TRANSLATIONSIMPORT_DEFAULT_TRANSLATIONSTIMELINE_DEFAULT_TRANSLATIONS —— 这三个包各自写了「不走工厂」的理由,但都手抄了同一个字面 needle,所以适用同一条规则。

实测量清楚地留在门外的两个邻面(写进正文而不是默默假设):

门不误伤什么,以及为什么是「按构造」

卡面和历届三轮分诊都点名了两类已知假阳:

  • 单花括号 {x}gantt.quickFilter.resultSummary{shown} / {total} 是设计上的下游洞(调用点做字面 .replace('{shown}', …))。按 [finding] Two spellings for "a hole i18next must NOT fill": auth.forgotPassword.successDescription uses {{email}}, its sibling three lines away uses {seconds} #4135 这恰恰是非 i18next 洞的正确拼形,报它等于把裁定反着执行。规则只检查 {{…}} 配对内部,单花括号在结构上就够不到判决。
  • JSX 对象字面量(style={{ opacity: 0 }}context={{ org }})。这是 TSX 语法不是文案 —— 历届每次裸 grep 扫描都撞上它。本门从不对源码文本 grep {{:它只读「经由文案表自身数据结构到达的字符串值」,而 JSX 的花括号不在字符串字面量里面。这一类是被扫描器看的位置排除的,不是被一张会腐烂的白名单排除的。

顺带交代与既有 i18n 门的分工:scripts/check-i18n-call-site-keys.mjsholesOf()抽取洞名时刻意读得通四种方言(注释写着「今天 en 的 84 个洞全是裸名字」),它容忍但从不拒绝;本门是同一条观察的执行半边 —— 把「今天全是裸名字」从一句实测变成一条规则。all-locales-key-parity.test.tsplaceholders match en 用的形状正则压根匹配不上 {{ name }},所以「en 和九个翻译一起改成带空格拼形」在它眼里形状相等、全绿 —— 那正是本门补上的绝对判据(包 vs fallback 语法),而不是相对判据(包 vs 包)。check-i18n-en-drift.mjs 只在 en发生变化时开火,第一天就写成带空格的值它永远看不到。

三面反向验证 + 两项误伤对照(每面先书面预判再跑)

变异前先 commit(29fbf9c),每次只改一处真实文案行,还原一律 git checkout(⛔ stash,按 CLAUDE.md 共享 stash 栈规则)。

预判方向是直红,不是倒挂也不是计数型:规则是对值的绝对谓词,没有 ?? 链、没有可以被变异掏空的判决计数。

# 变异 预判 实测
ListView.tsx:497 '{{count}} records''{{ count }} records' 恰好 1 条红:every table row uses only placeholders the fallback resolves,报出精确 file:line + 键 + 原因;其余 17 绿 1 failed / 17 passedpackages/plugin-list/src/ListView.tsx:497 list.recordCount = "{{ count }} records" / in LIST_DEFAULT_TRANSLATIONS (packages/plugin-list/src/ListView.tsx:629) / "{{ count }}" — whitespace inside the braces; the fallback resolves only {{name}}
en.ts:54 '{{count}} objects''{{count, number}} objects' 2 条红:en uses only placeholders… 之外,the scanner sees placeholders at all 这条正控的末尾断言也会红(它把规则重跑在 en 的全部拼形集合上);其余 16 绿 2 failed / 16 passeden pack, perm.facet.objects = "{{count, number}} objects" / an i18next format spec,正控收到 ["{{count, number}}"]
useGanttTranslation.ts:61 注入 $t(gantt.readOnly) 恰好 1 条红:同 ① 那条用例,报第 3 面手写表的 file:line 1 failed / 17 passedpackages/plugin-gantt/src/useGanttTranslation.ts:61 gantt.delete.body = … / in GANTT_DEFAULT_TRANSLATIONS / `$t(` — i18next nesting, which the fallback emits verbatim
对照 A ListView.tsx:497'{{count}} records {shown} {total}'(单花括号) 全绿 18/18 18 passed
对照 B ListView.tsx:2399style={{ opacity: 1, height: pullDistance }} data-ctx={{ org: 1 }} data-t={{ transform: 2 }} 全绿 18/18 —— 注意这是变异 ① 证明扫描器确实读过的同一个文件,所以「绿」只能是规则拒绝开火,不可能是文件没被扫到 18 passed

② 的两条红是提前写下来的:在 en 上注入必然同时触发那条正控,报一条反而说明我预判错了。

防空集假绿(零命中断言配「确定存在的邻近词」正查):the scanner sees placeholders at all 要求 en 包里 distinct 双花括号拼形 > 50(实测 90 种)且必须包含 {{count}}{{name}};discovers every table 要求发现的表 ≥ 34、行 ≥ 700、扫描文件 ≥ 1000,并且 unreadable 必须为空 —— 一张扫描器读不出的表是门上的洞,要报出来而不是静默跳过(唯一一条非字面量值 plugin-form/src/occSave.tsx'a' + 'b' 拼接因此被显式支持)。对照 A 里那条单花括号断言也配了正查:先确认 gantt.quickFilter.resultSummary 确实还在扫描集合里且确实是单花括号,再断言它绿。

完备性(防注册表腐烂)

第 3 面是列表,列表会腐烂。所以有一条用例钉住让它完备的那个事实:携带该字面 needle 的运行时文件集恰好是这四个 —— packages/i18n/src/useSafeTranslation.tsplugin-gantt/src/useGanttTranslation.tsplugin-grid/src/ImportWizard.tsxplugin-timeline/src/useTimelineTranslation.ts。第四份 fallbackT 手抄件落地时,这条用例会指名自己红掉,而不是让一张没上门的表溜过去。第 2 面则完全靠发现(工厂第一实参),新表写出来当天就自动进门。

门放在 packages/i18n 而两个面不在这个包里,为什么不算依赖倒置

defaults-maps-mirror-en-pack.test.ts 必须搬去 app-shell,是因为它 import 三张插件表,而那些包都依赖 @object-ui/i18n —— import 回来就把依赖倒过来了。本文件不 import 本包之外的任何东西:它把源文件当文本读进来再 parse,这在两个方向上都不构成模块依赖(与 forwardref-props-annotation.guard.test.ts 同一机制)。所以规则可以就近放在它所约束的那个 fallback 旁边 —— 下一个改 useSafeTranslation.ts 的人会往那儿看。typescript 已在 packages/i18n 的 devDependencies 里(^6.0.3),不引入幻影依赖。

验证

  • pnpm --workspace-concurrency=2 --filter '@object-ui/i18n^...' build —— clean(types / core)。
  • pnpm exec vitest run packages/i18n/ --maxWorkers=2 —— 46 files / 822 tests passed,新门 18/18。
  • pnpm exec turbo run type-check --concurrency=2 —— 81 successful, 81 total,EXIT=0。
  • node scripts/check-control-bytes.mjs —— OK(4386 tracked text files);另做超出该门扫描面的自查 grep -naP 于新增两文件,无命中。
  • node scripts/check-i18n-call-site-keys.mjs / check-i18n-en-drift.mjs / check-changeset-presence.mjs / check-changeset-no-major.mjs / check-changeset-fixed.mjs —— 全绿。

changeset

空 frontmatter,照 .changeset/app-shell-docs-nav-examples.md 的同类先例(测试-only、不发布任何包)。check-changeset-presence 明确认可这条豁免:「declared as releasing nothing, which is the explicit exemption and a complete answer to this gate」。


Generated by Claude Code

claude added 2 commits August 17, 2026 03:12
…er-less fallback reads (#3512)

createSafeTranslation's fallbackT interpolates with an exact literal needle
(`value.split(`{{${k}}}`)`), so it resolves `{{name}}` and nothing else, while
i18next — serving the same strings through a mounted provider — also resolves
`{{ name }}`, `{{count, number}}`, `{{- name}}` and `$t(nested)`. Per the #4135
ruling the fix is not a wider fallback but a spelling contract: `{{x}}` is
i18next-bound copy, `{x}` is a downstream-filled hole.

The gate reads only string VALUES reached through a copy table's own data
structure — the ten locale packs, the 31 createSafeTranslation defaults tables
(discovered from the factory's first argument) and the three hand-rolled
sibling tables — so JSX object literals and single-brace holes are out of range
by construction rather than by allow-list. Behaviour of the fallback itself is
unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
…sion of labour (#3512)

Two header corrections on the placeholder-spelling gate, no rule change:

- The inline `t(key, { defaultValue })` exclusion now carries the real census
  from `check:i18n-keys` (909 literal inline defaults, 906 pinned byte-identical
  to their `en` row, 3 not comparable, 62 computed) instead of a raw grep count,
  so the residual 65 with no transitive pin are stated rather than implied.
- Adds the division of labour with the three existing i18n gates, including the
  reason none of them can see this defect: `holesOf()` reads THROUGH all four
  i18next dialects and never rejects, the parity suite's shape regex cannot match
  a spaced placeholder at all, and en-drift only fires on a value CHANGING.

Also adds the changeset (empty frontmatter — test-only, releases nothing, the
explicit exemption `check-changeset-presence` names).

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-CwsSS7SZ.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.65KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 498.37KB 111.05KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 157.05KB 43.28KB
fields (index.js) 231.73KB 57.60KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.86KB 10.83KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.74KB
mobile (index.js) 1.50KB 0.62KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.42KB 1.42KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.62KB 12.83KB
plugin-charts (index.js) 64.75KB 18.37KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 130.85KB 33.44KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.81KB 59.97KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 120.43KB 29.04KB
plugin-gantt (index.js) 164.10KB 39.87KB
plugin-grid (index.js) 197.59KB 53.01KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.23KB 26.97KB
plugin-map (index.js) 17.91KB 5.72KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.70KB 11.19KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.53KB 9.41KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

PM 验收:ACCEPT(#3512,批次 16,PM 会话 session_01GTRjn8xBqp75dk7kFupVRt)

实物核验(已过):纯两新文件(门测试 669 行 + changeset 空 frontmatter 豁免形态),useSafeTranslation.ts 运行时零触碰 —— 严格执行晋级评论的「不拓宽 fallback,只建门」;标识 0;releases 0。

门的设计

反向验证:三变异(空格/格式化参数/$t( 嵌套)全部按预判形状红且报出精确 file:line+key,M2 预判「两处红」并中(含正查对照的连带红);两误伤对照(单花括号、JSX 字面量)注入 M1 已证明会被扫到的同一文件后保持绿 —— 「绿只能是规则拒绝触发,不可能是文件没被扫」,对照设计教科书级。非空下限(>50 拼形、≥34 表、≥700 行、>1000 文件)+ unreadable 必空(扫不动的表是门上的洞,报出而非跳过)。

PM 判断点裁定:语言包也按 fallback 文法收紧 —— 判为 #4135 已裁约定({{x}} 专属 i18next)的执行面而非新门禁强度决策,接受;dev 已把「维护者若偏好放开语言包面」的撤除路径写为 describe 块内的小型隔离编辑,否决窗留在此:维护者不同意可单独摘除该面,不影响其余门。

CI(亲读终态):20 项全 completed,18 success + 2 skipped,零失败。

→ undraft + auto-merge (SQUASH)。


Generated by Claude Code

@yinlianghui
yinlianghui marked this pull request as ready for review August 17, 2026 04:04
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 17, 2026
Merged via the queue into main with commit 9899b81 Aug 17, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3512-placeholder-gate branch August 17, 2026 04:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

createSafeTranslation 的无 provider 回退插值只认 {{name}} 一种写法,i18next 还认带空格 / 带格式化 / 嵌套三种

2 participants