test(i18n): 占位符拼写门 —— 无 provider 回退只会遇到它认得的 {{name}} 拼形 (#3512) - #4904
Merged
Conversation
…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>
This was referenced Aug 17, 2026
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
Collaborator
Author
|
PM 验收:ACCEPT(#3512,批次 16,PM 会话 实物核验(已过):纯两新文件(门测试 669 行 + changeset 空 frontmatter 豁免形态), 门的设计
反向验证:三变异(空格/格式化参数/ PM 判断点裁定:语言包也按 fallback 文法收紧 —— 判为 #4135 已裁约定( CI(亲读终态):20 项全 completed,18 success + 2 skipped,零失败。 → undraft + auto-merge (SQUASH)。 Generated by Claude Code |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3512
按 2026-08-17 晋级评论重切后的范围实施:不拓宽 fallback,建占位符拼写门。行为本体
packages/i18n/src/useSafeTranslation.ts一个字节都没改。分叉与裁定
createSafeTranslation的fallbackT用精确字面 needle 插值(value.split(`{{${k}}}`).join(String(v))),所以它只认{{name}}一种拼形;而挂了I18nProvider时服务同一批文案的 i18next 还认{{ name }}(花括号内空格)、{{count, number}}(格式化参数)、{{- name}}(反转义前缀)和$t(nested)(嵌套)。四种里任何一种写进文案,挂 provider 渲染正确、无 provider 就把花括号原样吐给用户 —— 不抛错、不打日志,而且专挑独立/嵌入式宿主,正是我们最看不到的运行环境。维护者 #4135 裁定(2026-08-11)已经把方向定死:
所以剩下的活不是教 fallback 再学三种方言(那等于长期维护第二个插值器去追 i18next),而是把文案约束到两条路径都认的那一种拼形上。declared = enforced:分叉从「靠运气不发生」变成「按构造不可达」。
这一条不是在重开 #3418 的裁定。#3418 是真实现 bug(
replace只替第一次,与 i18next 不一致),在生产端修掉了;这一条是声明一个受支持子集,#3512 卡面本身就把两者的性质区分开了,晋级评论按 #4135 裁定拍板。会被字面 needle 消费的文案源清单(在 97da1b0 上实测)
builtInLocalescreateSafeTranslation/createSafeTranslationHook默认表第 2 面按包展开:
components10 张(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-designer1(164 行)、plugin-detail1(148)、fields1(75)、plugin-dashboard2(75+6)、plugin-list2(62+9)、collaboration1(36)、plugin-grid2(18+11)、plugin-timeline1(16)、plugin-form4、plugin-kanban3、plugin-calendar1、plugin-view1、plugin-tree1。第 3 面是
GANTT_DEFAULT_TRANSLATIONS、IMPORT_DEFAULT_TRANSLATIONS、TIMELINE_DEFAULT_TRANSLATIONS—— 这三个包各自写了「不走工厂」的理由,但都手抄了同一个字面 needle,所以适用同一条规则。实测量清楚地留在门外的两个邻面(写进正文而不是默默假设):
t(key, { defaultValue }):fallbackT确实读它(createSafeTranslation 的无 provider 兜底从不读调用点的内联 defaultValue —— 只认自己那张 defaults 表,表里没有就把 raw key 渲染给用户(useDetailTranslation 实测 16 个 key) #3865 那一段链)。check:i18n-keys报 909 条字面内联默认值,其中 906 条被default-value-drift钉成与en行逐字节相同(3 条 not comparable,另有 62 条 computed 只报告不判)—— 所以绝大多数经由第 1 面被传递性覆盖。它是调用点选项而不是文案表,不在本卡重切的两个面里;残余缺口(那 3+62 条)已另立 finding 卡记账,今日违例数 0。ConcurrentUpdateDialog.tsx的.split('{{field}}'):硬编码 needle,消费的是一条包值而不是自带一张表 —— 第 1 面已经覆盖它 split 的那个值。门不误伤什么,以及为什么是「按构造」
卡面和历届三轮分诊都点名了两类已知假阳:
{x}。gantt.quickFilter.resultSummary的{shown}/{total}是设计上的下游洞(调用点做字面.replace('{shown}', …))。按 [finding] Two spellings for "a hole i18next must NOT fill":auth.forgotPassword.successDescriptionuses{{email}}, its sibling three lines away uses{seconds}#4135 这恰恰是非 i18next 洞的正确拼形,报它等于把裁定反着执行。规则只检查{{…}}配对内部,单花括号在结构上就够不到判决。style={{ opacity: 0 }}、context={{ org }})。这是 TSX 语法不是文案 —— 历届每次裸 grep 扫描都撞上它。本门从不对源码文本 grep{{:它只读「经由文案表自身数据结构到达的字符串值」,而 JSX 的花括号不在字符串字面量里面。这一类是被扫描器看的位置排除的,不是被一张会腐烂的白名单排除的。顺带交代与既有 i18n 门的分工:
scripts/check-i18n-call-site-keys.mjs的holesOf()在抽取洞名时刻意读得通四种方言(注释写着「今天en的 84 个洞全是裸名字」),它容忍但从不拒绝;本门是同一条观察的执行半边 —— 把「今天全是裸名字」从一句实测变成一条规则。all-locales-key-parity.test.ts的placeholders 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'every table row uses only placeholders the fallback resolves,报出精确 file:line + 键 + 原因;其余 17 绿packages/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'en uses only placeholders…之外,the scanner sees placeholders at all这条正控的末尾断言也会红(它把规则重跑在 en 的全部拼形集合上);其余 16 绿en pack, perm.facet.objects = "{{count, number}} objects"/an i18next format spec,正控收到["{{count, number}}"]useGanttTranslation.ts:61注入$t(gantt.readOnly)packages/plugin-gantt/src/useGanttTranslation.ts:61 gantt.delete.body = …/in GANTT_DEFAULT_TRANSLATIONS/`$t(` — i18next nesting, which the fallback emits verbatimListView.tsx:497→'{{count}} records {shown} {total}'(单花括号)ListView.tsx:2399→style={{ opacity: 1, height: pullDistance }} data-ctx={{ org: 1 }} data-t={{ transform: 2 }}② 的两条红是提前写下来的:在
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.ts、plugin-gantt/src/useGanttTranslation.ts、plugin-grid/src/ImportWizard.tsx、plugin-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