Uh oh!
There was an error while loading. Please reload this page.
fix(i18n): fail when a call site's arguments are not the holes its en value has, and delete the three that were inert (#3845) - #4136
Merged
Conversation
… value has (#3845) Adds a fourth failure class, `interpolation-parity`, to `scripts/check-i18n-call-site-keys.mjs`: for a call site whose key resolves to a readable `en` leaf, the set of interpolation option names it passes must EQUAL the set of `{{hole}}` names in that value. Both directions fail — an argument with no hole is dropped by i18next in silence, a hole with no argument leaves its own braces in what the user reads. The repo-wide run found 3 inert arguments and 0 unfilled holes, so the rule lands hard, with no baseline. All three arguments are deleted rather than answered with a new hole in `en.ts`, which would be a copy change obliging nine more packs: `marketplace.action.updateTo`'s `version`, `home.welcome`'s `product`, and `objectActions.resetPackageSetSuccess`'s `label`. No rendered output moves on any path — with a provider mounted i18next dropped them already, and react-i18next's `notReadyT` returns `defaultValue` verbatim with no interpolation step at all. `auth.forgotPassword.successDescription` is registered in EXTERNALLY_INTERPOLATED_HOLES: `ForgotPasswordForm` substitutes `{{email}}` itself once the form knows the address. That silences the unfilled direction only — passing `email` to `t()` there would make the form append it twice, and the gate still reports it. The spelling collision behind that entry is filed as #4135. objectui#3546's slice-five assertion, written to force this decision rather than let it be settled silently, now pins the chosen state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 10, 2026 12:15
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Aug 10, 2026
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 freeto 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#3845
Option C + A per the delegated ruling on the card. Stacked on #4119 (#3810), which merged as
297534b78before this started, so this branches offmainrather than off that branch.The rule (C)
scripts/check-i18n-call-site-keys.mjsgains a fourth failure class,interpolation-parity: for a call site whose key resolves to a readableenleaf, the set of interpolation option names it passes must equal the set of{{hole}}names in that value. Both directions fail, because they fail differently:{{name}}.Nothing else could see either.
all-locales-key-paritydoes compare placeholder shape, but pack against pack — ten packs agreeing onUpdatewhile the call site passesversionis full parity.check-i18n-en-driftfires only on a changedenstring. And #3810'sdefault-value-driftis satisfied the moment the call site's inline default matches the pack, which is exactly the roadhome.welcometravelled: its value was rewritten fromWelcome to {{product}}toBuild your business system with AI, #4119 aligned the call site's default to the new sentence, and the now-inertproductstayed sitting beside it. Byte-alignment and argument-parity are different questions about the same line.The measurement, which decided the shape
Run over the whole repo with the rule in and nothing yet fixed:
Five, where the card predicted two — and the three extra split cleanly into one more of the card's class and two of something the card did not anticipate.
One more inert argument the census had not reached
objectActions.resetPackageSetSuccesspasseslabel, copied from thedeleteSuccessbranch immediately below it, whose sentence does name the record. Checked against all ten packs by hand, not inferred fromen: not one of the ten has a{{label}}hole in that string, while all ten have one indeleteSuccess. Provably inert, so it takes the same treatment.Two that are NOT findings, and the abstention that says so
The two
auth.forgotPassword.successDescriptionsites are a false positive, and running the two-line fix on them would have been a real bug.packages/auth/src/ForgotPasswordForm.tsx:286fills that hole itself:The label is a prop; the call site renders before the user has typed an address and cannot interpolate it. The string travels through
t()with{{email}}intact — which works because i18next leaves an unmatched hole verbatim — and the component substitutes afterwards. All ten packs carry the hole, so this is the shape in every language.So the key is registered in a new
EXTERNALLY_INTERPOLATED_HOLESlist, modelled on the existingEXCLUDED_TRANSLATORS: an entry with a reason, not a silent skip. It removes the listed holes from the hole set, which silences the unfilled direction only — and that asymmetry is the point. Passingemailat the call site would let i18next consume the hole,includes('{{email}}')would then miss, and the fallback branch would append the address a second time. That is precisely the "fix" a reader would reach for on seeing a hole with no argument, so the gate keeps reporting it. The self-test re-verifies each entry's premise against both the pack and the named source file, so an entry cannot outlive the substitution it describes.The spelling collision underneath this — the same "hole i18next must not fill" concept written
{{email}}here and{seconds}three lines away in the same labels object, with only the second one documented — is filed as #4135 rather than fixed here: it is a ten-pack copy edit or a public-prop change in@object-ui/auth, neither of which belongs in a gate PR.The stock (A)
All three inert arguments deleted, none of the ten locale packs touched —
check:i18n-driftreports0 en value(s) changed. A, not B, per the ruling: adding{{version}}to the button is a copy decision about the three-stateUpdate / Installed / Installing...label and its width, and a stock cleanup must not smuggle one in. The gate accepts either resolution.No rendered output moves, on any path — verified rather than assumed, since #4119 found a provider-less nuance in the neighbouring class:
notReadyTreturnsoptsOrDefaultValue.defaultValueverbatim — read first-hand indist/es/useTranslation.js:5-14; there is no interpolation step on that path at all, so the argument was unused there too.Update,Build your business system with AI,Permission set reset to its shipped baseline) are themselves hole-free, so even a key that vanished would render the same.tfrom a bareuseObjectTranslation, so createSafeTranslation 的无 provider 兜底从不读调用点的内联 defaultValue —— 只认自己那张 defaults 表,表里没有就把 raw key 渲染给用户(useDetailTranslation 实测 16 个 key) #3865's third path does not arise.#3546's slice-five assertion — written explicitly to force this decision rather than let it be settled silently — is updated to pin the chosen state, not deleted: the argument is gone from the call site, no pack pretends there was a hole, and the sister
marketplace.install.updateTothat does render the version is asserted untouched, so the asymmetry stays visible to whoever takes the copy decision later.The false-positive trap the card documented
The card recorded that its first regex census scored 2 hits where there was 1, because the options object of
t('fields.image.enlarge', { name: ... })inpackages/fields/src/widgets/ImageField.tsx:105nests anothert()call, and the regex read the innerindex:as an outer argument. On an AST this is not a special case and must not become one — the inner call's arguments are its own, not properties of the outer object literal. Pinned both ways in the self-test: the real shape is silent, and an inner call that genuinely disagrees is still reported on its own call site.Abstentions, each one a decision
returnObjectssubtree, anenleaf that is not a readable static string. Plural families fall out here —t(k, { count })resolves through_one/_otherand there is no single value whose holes could be the answer, so they are never judged, consistent with how fix(i18n): fail on an inline defaultValue that contradicts its en value, and align the 43 that do (#3810) #4119 handled them.countis the reason: it is an i18next control option and the value of a{{count}}hole, so subtracting it from one side only would report every counted string as unfilled.replace:redirect, which is where i18next takes interpolation data from when present. 0 in the repo today; the abstention is counted and printed so the first one cannot erode the rule silently.holesOfreads through a formatter ({{n, number}}), an unescape marker ({{- html}}) and a keypath ({{user.name}}is filled byuser). None of the three is inentoday — all 84 distinct holes are bare names — but each would otherwise become a phantom hole nobody passes.Verification
pnpm exec vitest run scripts/__tests__/check-i18n-call-site-keys.test.ts— 51 passed (17 new).git checkout origin/main --of the script, patch re-applied after — nevergit stash): 13 of the 17 new tests go red, predicted before running. The 4 that stay green are the ones that should: two over-firing guards (a missing key must report['missing-key']only; a registered hole must not be demanded), themain-is-clean stock assertion, and the source-text pins on the three deleted arguments — none of which a removed rule can falsify.node scripts/check-i18n-call-site-keys.mjs— exit 0.pnpm exec vitest run packages/app-shell/— 318 files, 2969 passed, 1 skipped.pnpm exec vitest run packages/i18n/ scripts/__tests__/check-i18n-call-site-keys.test.ts— 36 files, 681 passed (includes the rewritten 258 个 t() 调用点引用的 key 在任何语言包里都不存在(#3530 守卫首跑实测),其中 8 处直接把 raw key 渲染给用户 #3546 slice-five assertion).pnpm type-check:scripts— exit 0.turbo run type-check lint --filter=@object-ui/app-shell --filter=@object-ui/i18n— 32 tasks successful, 0 lint errors.pnpm check:i18n-drift—0 en value(s) changed.node scripts/check-control-bytes.mjs— OK, 3829 files.check-changeset-presence/-fixed/-no-major— all green.Out of scope, filed separately
auth.forgotPassword.successDescriptionuses{{email}}, its sibling three lines away uses{seconds}#4135 — the two spellings for a hole i18next must not fill ({{email}}vs{seconds}), which is what made the twoForgotPasswordFormsites indistinguishable from real findings. Observation-class: correct in all ten languages today, and the registry plus the retained inert-direction check guard the one way it bites.Generated by Claude Code