Repository navigation
[finding] marketplace.action.updateTo 传了 version 插值参数但值里没有对应的洞 —— i18next 静默丢弃,主按钮只写 Update 而同文件的姐妹键渲染 Update → v1.2.3(全仓 1/1055) #3845
Description
Activity
Findings triage: promoted
finding→pm:queue.Basis: both anchors re-verified live on
origin/main@993336f—MarketplacePackagePage.tsx:555(param passed,defaultValue: 'Update', no hole) vs:852(sister key rendersUpdate → v{{version}}). The census is done (1/1055, single instance, false-positive already excluded with the nested-call caveat recorded), the #3546 slice-5 pin forces whoever touches this to decide deliberately — the remaining work is scoped.Direction recorded: C (gate) + A (delete the inert param) as the default, per the card's own lean — A changes no English rendering today, so no product semantics move; the gate is the same AST location as #3810's option-B gate (already
pm:queue), and the two criteria should land as one batch (its zero-baseline measurement is on this card). B (add{{version}}to the button) is a product/design call — not queued; if the maintainer wants the version on the main button, that's a one-line follow-up and the gate still stands either way.Dedup: family is #3810/#3530/#3650, all cross-linked, faces disjoint (this is the param-vs-hole criterion; #3810 is defaultValue-vs-en).
本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
第二个实例,而且它说明本单的检测判据缺了一半 —— #3546 切片六(
perm+home)在普查home.*调用点时撞到的:packages/app-shell/src/console/home/HomePage.tsx:296 t('home.welcome', { product: getRuntimeConfig().branding.productName, defaultValue: 'Welcome to {{product}}' }) en 包值(实际渲染): "Build your business system with AI" ← 没有 {{product}} 洞product参数因此是休眠的:白标部署的产品名一个字也不会出现在首屏大标题上,i18next 静默丢弃该参数。为什么本单当时只量到 1 处 / 1055: 本单的判据是"参数名 vs
defaultValue里的洞"。这一处的defaultValue有那个洞(Welcome to {{product}}),所以按该判据它是合规的;休眠是相对 en 包值发生的 —— 而包值才是 key 存在时真正渲染的东西。marketplace.action.updateTo之所以被抓到,是因为它连defaultValue里都没有洞。所以门禁判据应当是两段:
- key 在
en不存在时 → 参数名必须在defaultValue的洞里(本单原判据;marketplace.action.updateTo属此); - key 在
en存在时 → 参数名必须在en包值的洞里(home.welcome属此),并且十包一致(占位符形状已由all-locales-key-parity保证,所以只判 en 足够)。
第 2 段是全新的一段,而且是更常见的一段:2320 个字面量 key 里绝大多数已存在,所以本单"1 / 1055"的分母只覆盖了第 1 段。建议按两段各自重跑一遍再定级 —— 我这次只扫了
perm.*/home.*的 153 个站点(第 2 段命中 1 处),没有全仓跑。顺带确认本单正文那个陷阱在第 2 段同样成立:参数名提取必须先消掉嵌套的
t(),否则外层的参数对象会把内层调用的参数算进来(本单在ImageField.tsx:105上误报过一次)。关联:#3546 切片六 PR、#3810(同一处
home.welcome也是内联 defaultValue 与 en 包值语义分歧的一例,已在该单追评)。
Generated by Claude Code
- key 在
- added a commit that references this issue
on Aug 10, 2026 A second live instance of this class, measured during #3810's repo-wide census (all 2314 literal-key call sites, not a namespace slice):
packages/app-shell/src/console/home/HomePage.tsx:330t('home.welcome', { product: getRuntimeConfig().branding.productName, defaultValue: ... })home.welcomeinpackages/i18n/src/locales/en.tsisBuild your business system with AI— no{{product}}hole, so theproductparameter is silently dropped, exactly likemarketplace.action.updateTo'sversion. The call site's inlinedefaultValuereadWelcome to {{product}}, which is where the parameter came from and what makes the intent legible: the key's value was rewritten at some point and the call site was never revisited.So the count for the "argument passed, no hole to receive it" direction is now 2, in two unrelated namespaces — which is a point in favour of the bidirectional criterion this card proposes rather than a one-off fix. #3810's PR aligns that call site's dead
defaultValueto the pack value and deliberately leaves the parameter alone: dropping an argument, or adding a hole to theenvalue (which obliges the nine other packs via #3650), is this card's decision to make, not a rider on a byte-alignment PR.Worth pairing with the extractor note already recorded here: the
defaultValuerule that landed for #3810 sits at exactly the AST position this check needs (scripts/check-i18n-call-site-keys.mjs, the pack-backed call-site branch, where the options object is already walked fordefaultValue/returnObjects/ the probe flag), andcollectEnKeysnow returns leaf values as well as paths — so the "does this value have the hole this call site is filling" comparison needs no new machinery.
Generated by Claude Code
Claim: PM loop round 5 (rolling slot refill — UNFROZEN: this seat held the card while #3810/PR #4119 was in flight because both amend
scripts/check-i18n-call-site-keys.mjs; #4119 is accepted with auto-merge armed, so this now dispatches as its stacked successor)
Session:session_017Qqyix2QcnpUC9XeYVDzx3· Branch:claude/issue-3845-i18n-param-hole-parity· Worktree:objectui-issue-3845
File surface:scripts/check-i18n-call-site-keys.mjs(+ self-test) for the new rule;MarketplacePackagePage.tsx:555andHomePage.tsx:330(the second instance attached to this card by #4119's dev) for the stock; the #3546-slice-five pinning assertion that deliberately forces this decision. Serial constraints: stacks onclaude/issue-3810-defaultvalue-en-drift-gateif #4119 has not merged at start.
Container & model: S,mode:subagent,model: opusDelegated ruling (maintainer's 2026-08-10 delegation; veto window open): C + A — the card's own leaning, adopted. The gate: for
t(key, opts)with literal non-reserved option names and a key resolving inen, the option-name set must equal the en value's{{hole}}set, BOTH directions (param-no-hole red; hole-no-param red — declared=enforced). Stock is 1+1 sites (re-measure; the card's own scan method with the nested-call trap it documents), no baseline. A not B for the stock: delete the inertversionparams — B (adding{{version}}to ten packs) is a product decision about the main button's three-state copy and button width, exactly what a stock cleanup must not smuggle in; if the maintainer later wants the version shown, that is a one-key ten-pack change the gate will happily accept. The slice-five pinning assertion gets updated to pin the NEW chosen state (param gone) rather than deleted.
Generated by Claude Code
ACCEPT — held for the release window (objectui seat PM, session
session_017Qqyix2QcnpUC9XeYVDzx3, review of record) — PR #4136 reviewed and ready; NOT armed for merge per the freeze. Hold-for-release list: #4134, #4136.The ruling asked for C + A; the measurement turned up five sites, not two, and the report's centerpiece is the two it REFUSED to fix: both
forgotPassword.successDescriptionsites look like direction-two violations (unfilled{{email}}), but the component fills that hole itself after the fact — "fixing" them would have made i18next consume the hole, blinded the component's guard, and rendered the address twice. TheEXTERNALLY_INTERPOLATED_HOLESregistry is the right mechanism: reasoned entries that silence only the unfilled direction, still report a passed argument, and are re-verified by the self-test against BOTH the pack and the named source file so an entry cannot outlive the substitution it describes. The third inert argument (resetPackageSetSuccess'slabel, copied from a neighbor that HAS the hole) was hand-verified against all ten packs. The provider-less nuance was verified against react-i18next's actual dist (notReadyT does no interpolation) rather than assumed — the no-rendered-change changeset claim stands on that. Slice-five's forcing assertion updated to pin the chosen state, sister key untouched, zero pack changes. #4135 (two hole spellings,{{email}}vs{seconds}) well-filed as observation-class with the #3512 cross-link.The declared-interpolation contract is now enforced both directions with zero baseline. #4117 (the
|| 'fallback'spelling this rule structurally cannot see) unfreezes and dispatches next, stacked on this branch.
Generated by Claude Code
- added a commit that references this issue
on Aug 10, 2026
在 #3546 切片五(回填
marketplace+preview命名空间)顺手量出,不在该 PR 范围内。观察级:传进去的参数是休眠的,按钮上的Update本身是个正常标签,今天没有任何东西坏掉。现场
packages/app-shell/src/console/marketplace/MarketplacePackagePage.tsx:555(云端安装分支的主按钮):version: latestVersion被传进去,但defaultValue是Update—— 没有{{version}}的洞。i18next 对没有对应洞的插值参数静默丢弃,所以这个参数从来没有渲染过任何东西。对照:同一个文件的
:852(环境下拉里每个环境后面的版本提示)确实渲染版本:两个 key 名字都叫
updateTo、都传version、都在同一个包详情页上,只有一个有洞。作者意图看着是"主按钮也该显示要升到哪个版本",但那只是推测。全仓规模:1/1055,就是这一处
对全仓所有
t('key', { … })形态的调用点(key 在en能解析的,共 1055 个)做 AST 抽取,取出顶层参数名(剔除 i18next 保留名defaultValue/count/context/ns/lng/… 与嵌套调用的参数),与en值里的{{hole}}名字集合比对:这个类是单例。第一遍扫描曾报 2 处,第二处
packages/fields/src/widgets/ImageField.tsx:105是误报 —— 它的外层t('fields.image.enlarge', { name: … })的参数对象里嵌了另一个t()调用(t('fields.image.imageAlt', { index: idx + 1 })),我的正则把内层的index:当成了外层参数名。剔除嵌套括号跨度后复测为 1。记在这里是因为任何将来实现这道门禁的人会踩同一个坑:参数名提取必须先消掉嵌套调用。#3546 切片五做了什么(以及为什么不修)
切片五把
marketplace.action.updateTo补进十个语言包。硬纪律是en值与调用点内联defaultValue逐字节相同(36/36),所以en落的是Update,九包落各自的"更新 / Aktualisieren / Mettre à jour / …"(复用form.update既有译文行)。没有顺手给
en加{{version}},两个理由:Update→Update → v2.1.0),而这条纪律的整个意义就是回填不改变任何英文渲染;Installed/Installing…三态的视觉一致性都在里面。切片五的 PR 里有一条断言把这个选择钉住,免得下一个读者以为是漏了:
也就是说:要么删掉那个休眠参数,要么给十包都加上洞 —— 无论哪条,这条断言都会红,迫使做决定的人正面处理。
三道 i18n 门禁为什么看不见
check-i18n-call-site-keys.mjs([finding] 没有任何守卫断言组件 t() 引用的 key 存在于 en 包 —— parity 测试只管包际一致,调用点→包的一致性是盲区 #3530):只问 key 在en存不存在。all-locales-key-parity.test.ts:比包与包的占位符形状(九包必须和 en 一样),从不比"调用点传的参数"和"值里的洞"。check-i18n-en-drift.mjs(i18n 门禁:en 文案变更时其余九包必须同批跟改(或显式挂账)——#3582/#3625 族缺陷缺的那道不变量 #3650):只在 en 值变化时动作。反向的错(值里有洞、调用点不传参数 → 用户直接看到
{{name}}花括号)同样没人管。本次扫描顺带确认了这一支目前为 0,但那是运气,不是保证。收口方向(未裁决)
:555的version参数。零风险,承认按钮就叫Update。{{version}},与:852对齐成Update → v{{version}}。是产品改动,需要设计确认三态按钮宽度。t(key, opts)的非保留参数名集合必须与en值的{{hole}}名集合相等,双向都判 —— 传了没洞的参数红(本单),值里有洞没人传也红(声明即强制)。[finding] 没有任何守卫断言组件 t() 引用的 key 存在于 en 包 —— parity 测试只管包际一致,调用点→包的一致性是盲区 #3530 的守卫已经把调用点解析成 AST 了,增量接近零;存量按上面的实测是 1 处 + 0 处,不需要基线文件,可以直接落成硬门禁。倾向 C 落门禁 + A 清存量(A 而非 B,因为 B 是产品决定,不该由一道门禁的存量清理顺手带上)。若维护者认为主按钮该显示版本,那就是 B,并且门禁照样成立。
关联:#3546(切片五量出本条并钉住选择)、#3810(同一位置的另一道门禁提案:内联 defaultValue 必须等于 en 值;这两条判据应当同批实现)、#3530(守卫本体,已有现成 AST)、#3650(en-drift 门禁)。