From 1c85818ebc3e3af46edb6193cbda97ff9b31c3f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 05:53:28 +0000 Subject: [PATCH] fix(spec): reject retired key/defaultValue inline-map spellings by name; state the measured resolver behaviour in the message (#10492) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01B4h3medzvhB9rpfoja9jcw --- ...8n-inline-map-retired-spellings-by-name.md | 33 ++++++++++ packages/spec/src/ui/i18n.test.ts | 62 +++++++++++++++++++ packages/spec/src/ui/i18n.zod.ts | 27 +++++--- 3 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 .changeset/i18n-inline-map-retired-spellings-by-name.md diff --git a/.changeset/i18n-inline-map-retired-spellings-by-name.md b/.changeset/i18n-inline-map-retired-spellings-by-name.md new file mode 100644 index 0000000000..75e61e5a57 --- /dev/null +++ b/.changeset/i18n-inline-map-retired-spellings-by-name.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": minor +--- + +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) + +Two legs, both on `InlineLocaleMapSchema` in `packages/spec/src/ui/i18n.zod.ts`: + +1. **Message accuracy.** The `INLINE_LOCALE_KEY` rejection message said the + retired key-reference form (#5055) "resolves to nothing". Measured false: + both resolvers — `resolveI18nLabel` here and objectui's `pickLocalized`, + parity-pinned — fall through to their last resort (first string value, in + key insertion order) and return the raw dotted key, which renders as the + visible label. The message now states the measured behaviour. + +2. **Enforcement hole closed.** `key` is three letters — syntactically a valid + BCP-47 primary subtag — so `{ key: 'common.save' }` alone parsed as a + "language `key` inline locale map" and painted `common.save` on screen; the + pair form was rejected only because `defaultValue` fails the tag grammar. + The key pattern now refuses the two retired spellings by name, in any + combination, matching the emitted type's `{ key?: never; defaultValue?: + never }` narrowing (#9925, maintainer ruling 2026-08-19, option B). This is + an enforcement gap of the #5055 retirement, not a new contract: nothing else + is denied — real 2–3 letter subtags (`deu`, `fra`, `yue`) still parse. + +FROM → TO: a label authored as `{ key: '' }` (or any inline map +carrying a `key`/`defaultValue` entry) is now refused at parse time with the +named message; write the inline locale map form `{ en: '…', 'zh-CN': '…' }`, +or a plain string resolved through a translation bundle. This is the same +prescription the #5055 retirement and the #9925 type narrowing already carry — +the runtime now enforces what the type already refused. + + diff --git a/packages/spec/src/ui/i18n.test.ts b/packages/spec/src/ui/i18n.test.ts index 6590e34e47..8748c25cf1 100644 --- a/packages/spec/src/ui/i18n.test.ts +++ b/packages/spec/src/ui/i18n.test.ts @@ -85,6 +85,68 @@ describe('I18nLabelSchema', () => { })).toThrow(); }); + // ── #10492: the retired spellings are rejected BY NAME, in any combination ─ + // + // Before this, the pair above was rejected only because `defaultValue` fails + // the tag grammar — `key` is three letters, syntactically a valid BCP-47 + // primary subtag, so `{ key: 'common.save' }` ALONE parsed as a "language + // `key` locale map" and the resolvers' last resort (first string value) then + // painted the raw dotted key on screen. The emitted type had already made + // that spelling a compile error (#9925 `key?: never`); these pins hold the + // runtime to the same line. Each rejection pin asserts the named error + // content (issue code + message), not just parse failure. + + it('rejects a lone `key` — the enforcement hole #10492 closes', () => { + const r = I18nLabelSchema.safeParse({ key: 'common.save' }); + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues); + expect(issues).toContain('invalid_key'); + expect(issues).toContain('never by `key`/`defaultValue`'); + expect(issues).toContain('#5055'); + }); + + it('rejects a lone `defaultValue` with the same named error', () => { + const r = I18nLabelSchema.safeParse({ defaultValue: 'Save' }); + expect(r.success).toBe(false); + const issues = JSON.stringify(r.error?.issues); + expect(issues).toContain('invalid_key'); + expect(issues).toContain('never by `key`/`defaultValue`'); + }); + + it('rejects the retired spellings even when mixed with valid locale keys', () => { + for (const value of [ + { key: 'common.save', en: 'Save' }, + { en: 'Save', defaultValue: 'Save' }, + ]) { + const r = I18nLabelSchema.safeParse(value); + expect(r.success, `expected ${JSON.stringify(value)} to be REJECTED`).toBe(false); + expect(JSON.stringify(r.error?.issues)).toContain('invalid_key'); + } + }); + + it('the rejection message states the MEASURED behaviour, not "resolves to nothing"', () => { + // The message's old claim was measured false (#10492): both resolvers — + // `resolveI18nLabel` here and objectui's `pickLocalized`, parity-pinned — + // fall through to the first string value and return the raw key, which is + // worse than nothing: the machine key renders as the visible label. + const issues = JSON.stringify(I18nLabelSchema.safeParse({ key: 'common.save' }).error?.issues); + expect(issues).toContain('first string value'); + expect(issues).toContain('raw key is rendered'); + expect(issues).not.toContain('resolves to nothing'); + }); + + it('does NOT deny-list real 2–3 letter subtags — only the two retired spellings', () => { + // The narrowing is exactly `key`/`defaultValue`, never a claim about which + // English-looking words are languages: real ISO-639 subtags still parse. + for (const tag of ['deu', 'fra', 'yue', 'EN']) { + expect(I18nLabelSchema.safeParse({ [tag]: 'v' }).success, `${tag} must stay accepted`).toBe(true); + } + // And the issue's rejected probes stay rejected (grammar, not deny-list). + for (const bad of ['notALocale', 'x-private', 'e']) { + expect(I18nLabelSchema.safeParse({ [bad]: 'v' }).success, `${bad} must stay rejected`).toBe(false); + } + }); + it('should reject non-string, non-map values', () => { expect(() => I18nLabelSchema.parse(123)).toThrow(); expect(() => I18nLabelSchema.parse(true)).toThrow(); diff --git a/packages/spec/src/ui/i18n.zod.ts b/packages/spec/src/ui/i18n.zod.ts index db71404852..ffac1951d5 100644 --- a/packages/spec/src/ui/i18n.zod.ts +++ b/packages/spec/src/ui/i18n.zod.ts @@ -97,15 +97,20 @@ import { strictObject } from '../shared/strict-object'; * pages) uses `en` / `zh-CN` / `ja-JP` / `es-ES`, so the constraint costs no * real authoring surface. * - * What it rejects is the retired SHAPE, not a list of banned words: the - * key-reference form always carried `defaultValue` (required on the old - * `I18nObjectSchema`), which cannot be a language tag. A hypothetical map whose - * only key is a bare three-letter `key` still parses, because nothing - * distinguishes it from a language subtag without an ISO-639 registry — and a - * hand-curated deny-list of English words that "look like" tags would be a - * claim about languages this schema has no business making. + * The two retired spellings are rejected BY NAME, in any combination + * (#10492). An earlier revision of this comment argued the opposite — that the + * key-reference form "always carried `defaultValue`" and a lone three-letter + * `key` was indistinguishable from a language subtag — and that reasoning left + * an enforcement hole in the #5055 retirement: `{ key: 'common.save' }` alone + * parsed as a "language `key` locale map" and then rendered the raw dotted key + * on screen (the resolvers' last resort is the first string value — see the + * message below), while the emitted type had already made the same spelling a + * compile error (#9925's `key?: never` limb). Runtime and type axis now refuse + * the same two names. This is not a deny-list of English words that "look + * like" tags — it is exactly the two spellings #5055 retired, nothing else: + * `deu`, `fra`, or any other real three-letter subtag still parses. */ -const INLINE_LOCALE_KEY = /^(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/; +const INLINE_LOCALE_KEY = /^(?!(?:key|defaultValue)$)(default|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*)$/; /** * 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})*)$/; * ruling offered both and asked for a measured pick): a template-literal key * cannot express "2–3 letters", so its letter-union approximation both ADMITS * the lone `key` (three lowercase letters parse as a language subtag pattern — - * the same boundary the runtime doc below records) and explodes tsc (the + * the boundary the runtime refinement also had until #10492 closed it by name) + * and explodes tsc (the * 26-letter probe did not finish; a 12-letter scale took 16s where this shape * takes 2s), while a branded key breaks every existing object literal. The * narrowing is deliberately exactly the measured harm class, not BCP-47 @@ -189,7 +195,8 @@ export const InlineLocaleMapSchema: z.ZodType< z.string().regex( INLINE_LOCALE_KEY, 'an inline label map is keyed by BCP-47 locale tags (`en`, `zh-CN`, …) or `default` — ' - + 'not by `key`/`defaultValue`, which was the retired key-reference form (#5055) and resolves to nothing', + + 'never by `key`/`defaultValue`, the retired key-reference form (#5055): nothing looks the key up, ' + + 'so both resolvers fall through to the first string value and the raw key is rendered on screen', ), z.string(), ).describe('Inline locale map: BCP-47 tag → translated string'));