Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/i18n-label-resolver-parity-doc-sync.md
Original file line numberDiff line numberDiff line change
@@ -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.
189 changes: 150 additions & 39 deletions packages/spec/src/ui/i18n-label-resolver.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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<string, unknown>;
// 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);
}
Expand DownExpand Up@@ -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)', () => {
Expand DownExpand Up@@ -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(<tag>, 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`
Expand Down
82 changes: 56 additions & 26 deletions packages/spec/src/ui/i18n-label-resolver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
* |---|---|---|
Expand DownExpand Up@@ -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(<tag>,
* 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(<tag>, 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<string, string>`), 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
*
Expand Down
Loading