Uh oh!
There was an error while loading. Please reload this page.
test(i18n): 占位符拼写门 —— 无 provider 回退只会遇到它认得的 {{name}} 拼形 (#3512) - #4904
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>
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
commented
Aug 17, 2026
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 |
Uh oh!
There was an error while loading. Please reload this page.
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