Skip to content

Commit 55809a0

Browse files
Elon Muskclaude
andauthored
fix(spec): reject retired key/defaultValue inline-map spellings by name; state the measured resolver behaviour in the message (#10492) (#10644)
Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5649efb commit 55809a0

3 files changed

Lines changed: 112 additions & 10 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): reject the retired `key`/`defaultValue` spellings in inline locale maps BY NAME, in any combination — and stop claiming the retired form "resolves to nothing" (#10492)
6+
7+
Two legs, both on `InlineLocaleMapSchema` in `packages/spec/src/ui/i18n.zod.ts`:
8+
9+
1. **Message accuracy.** The `INLINE_LOCALE_KEY` rejection message said the
10+
retired key-reference form (#5055) "resolves to nothing". Measured false:
11+
both resolvers — `resolveI18nLabel` here and objectui's `pickLocalized`,
12+
parity-pinned — fall through to their last resort (first string value, in
13+
key insertion order) and return the raw dotted key, which renders as the
14+
visible label. The message now states the measured behaviour.
15+
16+
2. **Enforcement hole closed.** `key` is three letters — syntactically a valid
17+
BCP-47 primary subtag — so `{ key: 'common.save' }` alone parsed as a
18+
"language `key` inline locale map" and painted `common.save` on screen; the
19+
pair form was rejected only because `defaultValue` fails the tag grammar.
20+
The key pattern now refuses the two retired spellings by name, in any
21+
combination, matching the emitted type's `{ key?: never; defaultValue?:
22+
never }` narrowing (#9925, maintainer ruling 2026-08-19, option B). This is
23+
an enforcement gap of the #5055 retirement, not a new contract: nothing else
24+
is denied — real 2–3 letter subtags (`deu`, `fra`, `yue`) still parse.
25+
26+
FROM → TO: a label authored as `{ key: '<i18n.key>' }` (or any inline map
27+
carrying a `key`/`defaultValue` entry) is now refused at parse time with the
28+
named message; write the inline locale map form `{ en: '…', 'zh-CN': '…' }`,
29+
or a plain string resolved through a translation bundle. This is the same
30+
prescription the #5055 retirement and the #9925 type narrowing already carry —
31+
the runtime now enforces what the type already refused.
32+
33+
<!-- adr-0087: not-required (already-registered ui-widget-i18n-family-retired) the key-reference dialect's retirement record already carries this prescription; this change closes its runtime enforcement gap, no new migration -->

packages/spec/src/ui/i18n.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,68 @@ describe('I18nLabelSchema', () => {
8585
})).toThrow();
8686
});
8787

88+
// ── #10492: the retired spellings are rejected BY NAME, in any combination ─
89+
//
90+
// Before this, the pair above was rejected only because `defaultValue` fails
91+
// the tag grammar — `key` is three letters, syntactically a valid BCP-47
92+
// primary subtag, so `{ key: 'common.save' }` ALONE parsed as a "language
93+
// `key` locale map" and the resolvers' last resort (first string value) then
94+
// painted the raw dotted key on screen. The emitted type had already made
95+
// that spelling a compile error (#9925 `key?: never`); these pins hold the
96+
// runtime to the same line. Each rejection pin asserts the named error
97+
// content (issue code + message), not just parse failure.
98+
99+
it('rejects a lone `key` — the enforcement hole #10492 closes', () => {
100+
const r = I18nLabelSchema.safeParse({ key: 'common.save' });
101+
expect(r.success).toBe(false);
102+
const issues = JSON.stringify(r.error?.issues);
103+
expect(issues).toContain('invalid_key');
104+
expect(issues).toContain('never by `key`/`defaultValue`');
105+
expect(issues).toContain('#5055');
106+
});
107+
108+
it('rejects a lone `defaultValue` with the same named error', () => {
109+
const r = I18nLabelSchema.safeParse({ defaultValue: 'Save' });
110+
expect(r.success).toBe(false);
111+
const issues = JSON.stringify(r.error?.issues);
112+
expect(issues).toContain('invalid_key');
113+
expect(issues).toContain('never by `key`/`defaultValue`');
114+
});
115+
116+
it('rejects the retired spellings even when mixed with valid locale keys', () => {
117+
for (const value of [
118+
{ key: 'common.save', en: 'Save' },
119+
{ en: 'Save', defaultValue: 'Save' },
120+
]) {
121+
const r = I18nLabelSchema.safeParse(value);
122+
expect(r.success, `expected ${JSON.stringify(value)} to be REJECTED`).toBe(false);
123+
expect(JSON.stringify(r.error?.issues)).toContain('invalid_key');
124+
}
125+
});
126+
127+
it('the rejection message states the MEASURED behaviour, not "resolves to nothing"', () => {
128+
// The message's old claim was measured false (#10492): both resolvers —
129+
// `resolveI18nLabel` here and objectui's `pickLocalized`, parity-pinned —
130+
// fall through to the first string value and return the raw key, which is
131+
// worse than nothing: the machine key renders as the visible label.
132+
const issues = JSON.stringify(I18nLabelSchema.safeParse({ key: 'common.save' }).error?.issues);
133+
expect(issues).toContain('first string value');
134+
expect(issues).toContain('raw key is rendered');
135+
expect(issues).not.toContain('resolves to nothing');
136+
});
137+
138+
it('does NOT deny-list real 2–3 letter subtags — only the two retired spellings', () => {
139+
// The narrowing is exactly `key`/`defaultValue`, never a claim about which
140+
// English-looking words are languages: real ISO-639 subtags still parse.
141+
for (const tag of ['deu', 'fra', 'yue', 'EN']) {
142+
expect(I18nLabelSchema.safeParse({ [tag]: 'v' }).success, `${tag} must stay accepted`).toBe(true);
143+
}
144+
// And the issue's rejected probes stay rejected (grammar, not deny-list).
145+
for (const bad of ['notALocale', 'x-private', 'e']) {
146+
expect(I18nLabelSchema.safeParse({ [bad]: 'v' }).success, `${bad} must stay rejected`).toBe(false);
147+
}
148+
});
149+
88150
it('should reject non-string, non-map values', () => {
89151
expect(() => I18nLabelSchema.parse(123)).toThrow();
90152
expect(() => I18nLabelSchema.parse(true)).toThrow();

packages/spec/src/ui/i18n.zod.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,20 @@ import { strictObject } from '../shared/strict-object';
9797
* pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no
9898
* real authoring surface.
9999
*
100-
* What it rejects is the retired SHAPE, not a list of banned words: the
101-
* key-reference form always carried `defaultValue` (required on the old
102-
* `I18nObjectSchema`), which cannot be a language tag. A hypothetical map whose
103-
* only key is a bare three-letter `key` still parses, because nothing
104-
* distinguishes it from a language subtag without an ISO-639 registry — and a
105-
* hand-curated deny-list of English words that "look like" tags would be a
106-
* claim about languages this schema has no business making.
100+
* The two retired spellings are rejected BY NAME, in any combination
101+
* (#10492). An earlier revision of this comment argued the opposite — that the
102+
* key-reference form "always carried `defaultValue`" and a lone three-letter
103+
* `key` was indistinguishable from a language subtag — and that reasoning left
104+
* an enforcement hole in the #5055 retirement: `{ key: 'common.save' }` alone
105+
* parsed as a "language `key` locale map" and then rendered the raw dotted key
106+
* on screen (the resolvers' last resort is the first string value — see the
107+
* message below), while the emitted type had already made the same spelling a
108+
* compile error (#9925's `key?: never` limb). Runtime and type axis now refuse
109+
* the same two names. This is not a deny-list of English words that "look
110+
* like" tags — it is exactly the two spellings #5055 retired, nothing else:
111+
* `deu`, `fra`, or any other real three-letter subtag still parses.
107112
*/
108-
const INLINE_LOCALE_KEY = /^(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;
113+
const INLINE_LOCALE_KEY = /^(?!(?:key|defaultValue)$)(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;
109114

110115
/**
111116
* The emitted type of an inline locale map — hand-tied, because the key regex
@@ -137,7 +142,8 @@ const INLINE_LOCALE_KEY = /^(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/;
137142
* ruling offered both and asked for a measured pick): a template-literal key
138143
* cannot express "2–3 letters", so its letter-union approximation both ADMITS
139144
* the lone `key` (three lowercase letters parse as a language subtag pattern —
140-
* the same boundary the runtime doc below records) and explodes tsc (the
145+
* the boundary the runtime refinement also had until #10492 closed it by name)
146+
* and explodes tsc (the
141147
* 26-letter probe did not finish; a 12-letter scale took 16s where this shape
142148
* takes 2s), while a branded key breaks every existing object literal. The
143149
* narrowing is deliberately exactly the measured harm class, not BCP-47
@@ -189,7 +195,8 @@ export const InlineLocaleMapSchema: z.ZodType<
189195
z.string().regex(
190196
INLINE_LOCALE_KEY,
191197
'an inline label map is keyed by BCP-47 locale tags (`en`, `zh-CN`, …) or `default` — '
192-
+ 'not by `key`/`defaultValue`, which was the retired key-reference form (#5055) and resolves to nothing',
198+
+ 'never by `key`/`defaultValue`, the retired key-reference form (#5055): nothing looks the key up, '
199+
+ 'so both resolvers fall through to the first string value and the raw key is rendered on screen',
193200
),
194201
z.string(),
195202
).describe('Inline locale map: BCP-47 tag → translated string'));

0 commit comments

Comments
 (0)