From b9971441474f7ad33248247957e1b850b1bebfe2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 08:45:57 +0000 Subject: [PATCH] docs(spec): sync i18n-label-resolver's pickLocalized reference to objectui#3907 (#7864) objectui PR #4359 hardened pickLocalized to own-property reads plus a string filter on every limb, collapsing to zero the two rule departures resolveI18nLabel documented as deliberate narrowings of its reference. The verbatim copy embedded in i18n-label-resolver.test.ts stayed green while silently stale. - Refresh the verbatim reference copy to objectui origin/main d8d0d66 / blob 30fcb0a8 and update the pin comment. - Flip the two assertions that pinned the old prototype-chain and object-stringification behavior to the converged answers (a guard makes its limb miss, it does not abort resolution). - Mirror the 18-vector CONVERGED table from objectui's plugin-list parity suite into PARITY_VECTORS as the sync's acceptance fixture. - Reword the "the two deliberate departures" describe title and the resolver's module doc to record the convergence, keeping only the two differences that survive: the miss spelling ('' vs undefined) and the top-level scalar pass-through, which objectui#3907 did not touch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 --- .../i18n-label-resolver-parity-doc-sync.md | 24 +++ .../spec/src/ui/i18n-label-resolver.test.ts | 189 ++++++++++++++---- packages/spec/src/ui/i18n-label-resolver.ts | 82 +++++--- 3 files changed, 230 insertions(+), 65 deletions(-) create mode 100644 .changeset/i18n-label-resolver-parity-doc-sync.md diff --git a/.changeset/i18n-label-resolver-parity-doc-sync.md b/.changeset/i18n-label-resolver-parity-doc-sync.md new file mode 100644 index 0000000000..d094ddf899 --- /dev/null +++ b/.changeset/i18n-label-resolver-parity-doc-sync.md @@ -0,0 +1,24 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): sync `resolveI18nLabel`'s reference fixture and module doc to objectui's post-#3907 `pickLocalized` (#7864) + +`resolveI18nLabel` (`packages/spec/src/ui/i18n-label-resolver.ts`) documented +two deliberate narrowings of its reference, objectui's `pickLocalized`: own- +property-only reads and a `string` filter on every limb. objectui PR #4359 +(objectui#3907) landed both guards upstream, so the rule departures are now +zero — this is a documentation and test-fixture sync, not a behavior change to +`resolveI18nLabel` itself. + +- The module doc's "Rule departures" section is reworded to record the + convergence and the two differences that survive it: the miss spelling + (`''` vs `undefined`) and the top-level scalar pass-through (`pickLocalized` + stringifies a bare number/boolean; `resolveI18nLabel`'s declared parameter + type refuses one). This prose reaches `dist/**/*.d.ts`. +- `i18n-label-resolver.test.ts`'s verbatim reference copy is refreshed to + objectui `origin/main d8d0d66` / blob `30fcb0a8`, its two stale assertions + are flipped to the converged answers (a guard makes its limb MISS, it does + not abort resolution), and the 18-vector CONVERGED table mirrored from + objectui's `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` is + added as an acceptance fixture. diff --git a/packages/spec/src/ui/i18n-label-resolver.test.ts b/packages/spec/src/ui/i18n-label-resolver.test.ts index 53035c4e2a..e249f55fa8 100644 --- a/packages/spec/src/ui/i18n-label-resolver.test.ts +++ b/packages/spec/src/ui/i18n-label-resolver.test.ts @@ -18,9 +18,9 @@ * * repo objectstack-ai/objectui * path packages/i18n/src/pickLocalized.ts - * rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f - * blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec - * last touched by objectui#3278 (2026-08-03) + * rev origin/main d8d0d665dceba53dada4994f1eeef9f83bf1cf91 + * blob 30fcb0a86343b9b937432a1b2ea89e0a78321f46 + * last touched by objectui PR #4359 / objectui#3907 (2026-08-11) * * Copied rather than imported because `@objectstack/spec` must not take a * workspace dependency on objectui — spec sits UNDER objectui in the dependency @@ -59,19 +59,26 @@ function pickLocalizedReference(value: unknown, language: string | undefined | n if (typeof value === 'number' || typeof value === 'boolean') return String(value); if (typeof value === 'object') { const o = value as Record; + // Own properties only, and only `string` values, on every limb + // (objectui#3907) — see `readReference` below. + const readReference = (key: string): string | undefined => { + if (!Object.prototype.hasOwnProperty.call(o, key)) return undefined; + const entry = o[key]; + return typeof entry === 'string' ? entry : undefined; + }; const lang = (language || 'en').trim(); const base = lang.split('-')[0]; // Runtime language is often a bare base code ('zh') while metadata authors // write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base. - const regional = Object.keys(o).find((k) => k.split('-')[0] === base && typeof o[k] === 'string'); + const regional = Object.keys(o).find((k) => k.split('-')[0] === base && readReference(k) !== undefined); const pick = - o[lang] ?? - o[base] ?? - (regional !== undefined ? o[regional] : undefined) ?? - o.default ?? - o.en ?? - Object.values(o).find((v) => typeof v === 'string'); - return pick == null ? '' : String(pick); + readReference(lang) ?? + readReference(base) ?? + (regional !== undefined ? readReference(regional) : undefined) ?? + readReference('default') ?? + readReference('en') ?? + Object.values(o).find((v): v is string => typeof v === 'string'); + return pick == null ? '' : pick; } return String(value); } @@ -162,6 +169,107 @@ const PARITY_VECTORS: readonly ParityVector[] = [ // The miss cases. The reference spells "nothing was picked" as `''`. { limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' }, { limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' }, + + // Converged as of objectui#3907 (PR objectui#4359) — before that PR these 18 + // vectors were the ONLY inputs that told the two implementations apart (see + // the module doc's "Rule departures" section). Mirrored from objectui's + // `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` `CONVERGED` + // table, reused here per objectstack#7864 as the sync's acceptance fixture. + // A guard makes its limb MISS, not abort — the chain falls through to the + // next limb exactly as an absent entry would. + { + limb: 'converged: locale names an Object.prototype member ⇒ miss, falls through to en', + label: { en: 'Sales' }, + locale: 'constructor', + pick: 'Sales', + }, + { limb: 'converged: prototype member "toString" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'toString', pick: 'Sales' }, + { limb: 'converged: prototype member "valueOf" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'valueOf', pick: 'Sales' }, + { + limb: 'converged: prototype member "hasOwnProperty" ⇒ miss, falls through to en', + label: { en: 'Sales' }, + locale: 'hasOwnProperty', + pick: 'Sales', + }, + { + limb: 'converged: prototype member "isPrototypeOf" ⇒ miss, falls through to en', + label: { en: 'Sales' }, + locale: 'isPrototypeOf', + pick: 'Sales', + }, + { + limb: 'converged: prototype member "propertyIsEnumerable" ⇒ miss, falls through to en', + label: { en: 'Sales' }, + locale: 'propertyIsEnumerable', + pick: 'Sales', + }, + { + limb: 'converged: prototype member "toLocaleString" ⇒ miss, falls through to en', + label: { en: 'Sales' }, + locale: 'toLocaleString', + pick: 'Sales', + }, + { + limb: 'converged: no `en` either ⇒ falls all the way to the last-resort limb', + label: { ja: '営業' }, + locale: 'constructor', + pick: '営業', + }, + { limb: 'converged: empty map + prototype-named locale ⇒ a genuine miss', label: {}, locale: 'constructor', pick: '' }, + { + limb: 'converged: an OWN key really named like a prototype member is still the author\'s key', + label: { constructor: 'Ctor', en: 'Sales' }, + locale: 'constructor', + pick: 'Ctor', + }, + { + limb: 'converged: non-string value on limb 1 (exact tag) ⇒ miss', + label: { 'zh-CN': { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel, + locale: 'zh-CN', + pick: 'Sales', + }, + { + limb: 'converged: non-string value on limb 2 (base) ⇒ miss', + label: { zh: { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel, + locale: 'zh-CN', + pick: 'Sales', + }, + { + limb: 'converged: non-string value on limb 4 (default) ⇒ miss', + label: { default: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel, + locale: 'fr', + pick: '営業', + }, + { + limb: 'converged: non-string value on limb 5 (en) ⇒ miss', + label: { en: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel, + locale: 'fr', + pick: '営業', + }, + { + limb: 'converged: a number value ⇒ miss', + label: { en: 42, ja: '営業' } as unknown as I18nLabel, + locale: 'fr', + pick: '営業', + }, + { + limb: 'converged: a null value ⇒ miss', + label: { en: null, ja: '営業' } as unknown as I18nLabel, + locale: 'fr', + pick: '営業', + }, + { + limb: 'converged: nothing usable anywhere ⇒ a genuine miss', + label: { en: { nested: 'x' } } as unknown as I18nLabel, + locale: 'fr', + pick: '', + }, + { + limb: 'converged: an empty string value still HITS and stops the chain', + label: { en: '', 'zh-CN': '销售' }, + locale: 'en', + pick: '', + }, ]; describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => { @@ -223,47 +331,50 @@ describe('resolveI18nLabel — the producer-facing return shape', () => { }); }); -describe('resolveI18nLabel — the two deliberate departures from the reference', () => { - // Both are documented on the resolver's module doc. They are pinned here with - // BOTH answers so the divergence stays MEASURED: if a later change makes the - // two agree again, these tests go red and say so, rather than quietly - // becoming decoration. - - it('reads own properties only — a locale naming an Object.prototype member is a miss', () => { +describe('resolveI18nLabel — the rule departures converged with objectui#3907; one departure survives', () => { + // Before objectui#3907 (landed as objectui PR #4359) this module's rule + // documented two DELIBERATE narrowings of the reference. #4359 landed the + // same two guards upstream, so the copied reference now answers identically + // to `resolveI18nLabel` on both. Pinned here with BOTH sides so the + // convergence stays MEASURED: if a later change on either side reopens the + // gap, these tests go red and say so, rather than quietly rotting back into + // decoration. See `PARITY_VECTORS`' "converged:" rows above for the fuller + // 18-vector sweep mirrored from objectui's own parity suite. + + it('reads own properties only, now on BOTH sides — a locale naming an Object.prototype member misses and falls through', () => { const label: I18nLabel = { en: 'Owner' }; - // The reference resolves `map['constructor']` up the prototype chain and - // renders the function's source text as the label. Filed as objectui#3907. - expect(pickLocalizedReference(label, 'constructor')).toContain('function Object'); - - // Here it is simply not a key, so the chain continues to `en`. No BCP-47 - // tag is an `Object.prototype` member, so no in-contract input can tell the - // two implementations apart — but on a server the locale can arrive in an - // `Accept-Language` header, which is why this one is hardened. + // Before objectui#3907 the reference resolved `map['constructor']` up the + // prototype chain and rendered the function's source text as the label. + // A guard makes its limb MISS; it does not abort — the chain now falls + // through to `en` on both sides, converged by objectui PR #4359. + expect(pickLocalizedReference(label, 'constructor')).toBe('Owner'); expect(resolveI18nLabel(label, 'constructor')).toBe('Owner'); expect(resolveI18nLabel(label, 'toString')).toBe('Owner'); }); - it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => { + it('treats a non-string value as absent on EVERY limb, now on BOTH sides', () => { // Off-spec: `InlineLocaleMapSchema` is `z.record(, z.string())`, so no // in-contract map can hold this. The cast is what makes that explicit. const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel; - // The reference filters by `typeof === 'string'` on limbs 3 and 6 but not - // on 1/2/4/5, so an exact-tag hit short-circuits and gets stringified. - expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('[object Object]'); - - // PD#12: the producer is wrong; the consumer must not coerce `[object - // Object]` onto a screen. The filter is uniform, so the limb is a miss and - // the chain continues. + // Before objectui#3907 the reference filtered by `typeof === 'string'` on + // limbs 3 and 6 but not on 1/2/4/5, so an exact-tag hit short-circuited + // and got stringified as `[object Object]`. The filter is uniform on both + // sides now, so the limb is a miss and the chain continues to `en`. + expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('Owner'); expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner'); }); - it('refuses an off-contract scalar rather than stringifying it', () => { - // `pickLocalized` accepts `unknown` and stringifies numbers/booleans. This - // resolver's parameter is the declared `I18nLabel`, so the shapes below are - // type errors — the `@ts-expect-error` directives immediately after are the - // real guard. This asserts the runtime half: no coerced `'42'` label. + it('refuses an off-contract scalar rather than stringifying it — the one departure objectui#3907 did NOT touch', () => { + // `pickLocalized` accepts `unknown` and stringifies numbers/booleans + // (`pickLocalized(42, 'en')` is `'42'`). This resolver's parameter is the + // declared `I18nLabel`, so the shapes below are type errors — the + // `@ts-expect-error` directives immediately after are the real guard. + // This asserts the runtime half: no coerced `'42'` label. objectui#3907 / + // PR objectui#4359 hardened the MAP limbs only; it never touched this + // top-level VALUE-parameter case, so it is the one departure that + // survives the sync (objectstack#7864). // @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced expect(resolveI18nLabel(42, 'en')).toBeUndefined(); // @ts-expect-error a boolean is not an `I18nLabel` diff --git a/packages/spec/src/ui/i18n-label-resolver.ts b/packages/spec/src/ui/i18n-label-resolver.ts index 53bd3aad71..cb714bad09 100644 --- a/packages/spec/src/ui/i18n-label-resolver.ts +++ b/packages/spec/src/ui/i18n-label-resolver.ts @@ -32,8 +32,8 @@ * client-rendered one simply differ. * * The reference is objectui `packages/i18n/src/pickLocalized.ts` (read at - * `origin/main` `50fa376`, blob `9e5d92a`, last touched by objectui#3278). Its - * rule, mirrored here limb for limb: + * `origin/main` `d8d0d66`, blob `30fcb0a8`, last touched by objectui PR #4359 / + * objectui#3907). Its rule, mirrored here limb for limb: * * | # | limb | note | * |---|---|---| @@ -71,30 +71,60 @@ * `i18n-label-resolver.test.ts`; changing it is a two-repo decision, not a * detail to fix in passing. * - * ## Two deliberate departures, both narrower than the rule above - * - * 1. **Own properties only.** `pickLocalized` reads `map[locale]` with a bare - * bracket access, so a locale that happens to name an `Object.prototype` - * member (`constructor`, `toString`) resolves to that member and renders as - * its source text. In a browser the locale comes from the app's own language - * state; on a server it can come from an `Accept-Language` header, so this - * module reads own properties only. No language tag is an `Object.prototype` - * key, so no in-contract input can tell the two apart. Filed against the - * reference as objectui#3907. - * 2. **Only `string` values are eligible, on every limb.** `pickLocalized` - * applies a `typeof === 'string'` filter on limbs 3 and 6 but not on 1, 2, 4, - * 5, where a non-string value short-circuits the chain and is stringified - * (`[object Object]`). `InlineLocaleMapSchema` declares `z.record(, - * z.string())`, so no value that reaches either resolver in-contract is - * anything but a string, and the inconsistency is unobservable inside the - * declared domain. Out of contract, Prime Directive #12 says the producer is - * wrong and the consumer must not coerce a rendered `[object Object]` onto a - * screen — so the filter is applied uniformly and an off-spec value is - * treated as absent. - * - * Both are stated rather than silent because parity, not taste, is what this - * module is for: everything a caller can observe with an `I18nLabel` that - * `I18nLabelSchema` accepts is identical between the two ends. + * ## Rule departures — converged as of objectui#3907, zero remain + * + * This module shipped (objectstack#6765) with two deliberate narrowings of the + * reference's limb rule, both stated rather than silent. objectui#3907 (landed + * in objectui PR #4359) closed both upstream, so the two ends now agree limb + * for limb on every map input: + * + * 1. **Own properties only.** Before objectui#3907, `pickLocalized` read + * `map[locale]` with a bare bracket access, so a locale that happened to + * name an `Object.prototype` member (`constructor`, `toString`) resolved to + * that member and rendered as its source text. In a browser the locale + * comes from the app's own language state; on a server it can come from an + * `Accept-Language` header, so this module read own properties only from + * the start. No language tag is an `Object.prototype` key, so no + * in-contract input could ever tell the two apart — objectui#3907 landed + * the same guard upstream regardless, closing the gap for out-of-contract + * input too. + * 2. **Only `string` values are eligible, on every limb.** Before + * objectui#3907, `pickLocalized` applied a `typeof === 'string'` filter on + * limbs 3 and 6 but not on 1, 2, 4, 5, where a non-string value + * short-circuited the chain and was stringified (`[object Object]`). + * `InlineLocaleMapSchema` declares `z.record(, z.string())`, so no + * value that reaches either resolver in-contract is anything but a string, + * and the inconsistency was unobservable inside the declared domain — but + * Prime Directive #12 says the producer is wrong and the consumer must not + * coerce a rendered `[object Object]` onto a screen, and objectui#3907 + * applies the filter uniformly upstream now too. + * + * A guard makes its limb **miss**; it does not abort the resolution — an + * unusable entry falls through to the next limb exactly as an absent one does, + * on both sides. `pickLocalized({ en: 'Pricing' }, 'constructor')` is now + * `'Pricing'` (the chain falls through to the `en` limb), not `''` — `''` (or + * this module's `undefined`) only when NO limb hits at all, e.g. an empty map. + * + * ## What still differs + * + * Two differences survive the sync, both stated rather than silent because + * parity, not taste, is what this module is for — everything else a caller + * can observe with an `I18nLabel` that `I18nLabelSchema` accepts is identical + * between the two ends: + * + * * **The miss spelling.** `pickLocalized` answers a miss as `''`, because its + * caller is a renderer writing into a text node. This module answers + * `undefined` — see the return-shape note below. The two spellings are + * bridged by one `?? ''`, pinned as an identity in the parity test. + * * **The top-level scalar pass-through.** `pickLocalized` accepts `unknown` + * and stringifies a bare number or boolean (`pickLocalized(42, 'en')` is + * `'42'`). This module's parameter is the declared `I18nLabel` + * (`string | Record`), so a number or boolean is a type + * error at the call site, not a value to coerce at runtime — + * `resolveI18nLabel(42, 'en')` is `undefined`, refused rather than + * stringified (Prime Directive #12). This is a departure in the VALUE + * parameter, not a limb of the map rule above, and objectui#3907 / PR + * objectui#4359 did not touch it — it is not expected to converge. * * ## Relation to the other resolver in this package *