Skip to content

Commit b85cc54

Browse files
os-zhuangclaude
andauthored
docs(spec): sync i18n-label-resolver's pickLocalized reference to objectui#3907 (#7864) (#7953)
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. Claude-Session: https://claude.ai/code/session_016YBUGvukaeVu9DjKdsHJa9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 078c448 commit b85cc54

3 files changed

Lines changed: 230 additions & 65 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): sync `resolveI18nLabel`'s reference fixture and module doc to objectui's post-#3907 `pickLocalized` (#7864)
6+
7+
`resolveI18nLabel` (`packages/spec/src/ui/i18n-label-resolver.ts`) documented
8+
two deliberate narrowings of its reference, objectui's `pickLocalized`: own-
9+
property-only reads and a `string` filter on every limb. objectui PR #4359
10+
(objectui#3907) landed both guards upstream, so the rule departures are now
11+
zero — this is a documentation and test-fixture sync, not a behavior change to
12+
`resolveI18nLabel` itself.
13+
14+
- The module doc's "Rule departures" section is reworded to record the
15+
convergence and the two differences that survive it: the miss spelling
16+
(`''` vs `undefined`) and the top-level scalar pass-through (`pickLocalized`
17+
stringifies a bare number/boolean; `resolveI18nLabel`'s declared parameter
18+
type refuses one). This prose reaches `dist/**/*.d.ts`.
19+
- `i18n-label-resolver.test.ts`'s verbatim reference copy is refreshed to
20+
objectui `origin/main d8d0d66` / blob `30fcb0a8`, its two stale assertions
21+
are flipped to the converged answers (a guard makes its limb MISS, it does
22+
not abort resolution), and the 18-vector CONVERGED table mirrored from
23+
objectui's `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` is
24+
added as an acceptance fixture.

packages/spec/src/ui/i18n-label-resolver.test.ts

Lines changed: 150 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
*
1919
* repo objectstack-ai/objectui
2020
* path packages/i18n/src/pickLocalized.ts
21-
* rev origin/main 50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f
22-
* blob 9e5d92ae2efe9be62d4d010cb0a26e598211f3ec
23-
* last touched by objectui#3278 (2026-08-03)
21+
* rev origin/main d8d0d665dceba53dada4994f1eeef9f83bf1cf91
22+
* blob 30fcb0a86343b9b937432a1b2ea89e0a78321f46
23+
* last touched by objectui PR #4359 / objectui#3907 (2026-08-11)
2424
*
2525
* Copied rather than imported because `@objectstack/spec` must not take a
2626
* workspace dependency on objectui — spec sits UNDER objectui in the dependency
@@ -59,19 +59,26 @@ function pickLocalizedReference(value: unknown, language: string | undefined | n
5959
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
6060
if (typeof value === 'object') {
6161
const o = value as Record<string, unknown>;
62+
// Own properties only, and only `string` values, on every limb
63+
// (objectui#3907) — see `readReference` below.
64+
const readReference = (key: string): string | undefined => {
65+
if (!Object.prototype.hasOwnProperty.call(o, key)) return undefined;
66+
const entry = o[key];
67+
return typeof entry === 'string' ? entry : undefined;
68+
};
6269
const lang = (language || 'en').trim();
6370
const base = lang.split('-')[0];
6471
// Runtime language is often a bare base code ('zh') while metadata authors
6572
// write full BCP-47 tags ('zh-CN') — upgrade to any key sharing the base.
66-
const regional = Object.keys(o).find((k) => k.split('-')[0] === base && typeof o[k] === 'string');
73+
const regional = Object.keys(o).find((k) => k.split('-')[0] === base && readReference(k) !== undefined);
6774
const pick =
68-
o[lang] ??
69-
o[base] ??
70-
(regional !== undefined ? o[regional] : undefined) ??
71-
o.default ??
72-
o.en ??
73-
Object.values(o).find((v) => typeof v === 'string');
74-
return pick == null ? '' : String(pick);
75+
readReference(lang) ??
76+
readReference(base) ??
77+
(regional !== undefined ? readReference(regional) : undefined) ??
78+
readReference('default') ??
79+
readReference('en') ??
80+
Object.values(o).find((v): v is string => typeof v === 'string');
81+
return pick == null ? '' : pick;
7582
}
7683
return String(value);
7784
}
@@ -162,6 +169,107 @@ const PARITY_VECTORS: readonly ParityVector[] = [
162169
// The miss cases. The reference spells "nothing was picked" as `''`.
163170
{ limb: 'miss empty map', label: {}, locale: 'zh-CN', pick: '' },
164171
{ limb: 'miss absent label', label: undefined, locale: 'zh-CN', pick: '' },
172+
173+
// Converged as of objectui#3907 (PR objectui#4359) — before that PR these 18
174+
// vectors were the ONLY inputs that told the two implementations apart (see
175+
// the module doc's "Rule departures" section). Mirrored from objectui's
176+
// `plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts` `CONVERGED`
177+
// table, reused here per objectstack#7864 as the sync's acceptance fixture.
178+
// A guard makes its limb MISS, not abort — the chain falls through to the
179+
// next limb exactly as an absent entry would.
180+
{
181+
limb: 'converged: locale names an Object.prototype member ⇒ miss, falls through to en',
182+
label: { en: 'Sales' },
183+
locale: 'constructor',
184+
pick: 'Sales',
185+
},
186+
{ limb: 'converged: prototype member "toString" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'toString', pick: 'Sales' },
187+
{ limb: 'converged: prototype member "valueOf" ⇒ miss, falls through to en', label: { en: 'Sales' }, locale: 'valueOf', pick: 'Sales' },
188+
{
189+
limb: 'converged: prototype member "hasOwnProperty" ⇒ miss, falls through to en',
190+
label: { en: 'Sales' },
191+
locale: 'hasOwnProperty',
192+
pick: 'Sales',
193+
},
194+
{
195+
limb: 'converged: prototype member "isPrototypeOf" ⇒ miss, falls through to en',
196+
label: { en: 'Sales' },
197+
locale: 'isPrototypeOf',
198+
pick: 'Sales',
199+
},
200+
{
201+
limb: 'converged: prototype member "propertyIsEnumerable" ⇒ miss, falls through to en',
202+
label: { en: 'Sales' },
203+
locale: 'propertyIsEnumerable',
204+
pick: 'Sales',
205+
},
206+
{
207+
limb: 'converged: prototype member "toLocaleString" ⇒ miss, falls through to en',
208+
label: { en: 'Sales' },
209+
locale: 'toLocaleString',
210+
pick: 'Sales',
211+
},
212+
{
213+
limb: 'converged: no `en` either ⇒ falls all the way to the last-resort limb',
214+
label: { ja: '営業' },
215+
locale: 'constructor',
216+
pick: '営業',
217+
},
218+
{ limb: 'converged: empty map + prototype-named locale ⇒ a genuine miss', label: {}, locale: 'constructor', pick: '' },
219+
{
220+
limb: 'converged: an OWN key really named like a prototype member is still the author\'s key',
221+
label: { constructor: 'Ctor', en: 'Sales' },
222+
locale: 'constructor',
223+
pick: 'Ctor',
224+
},
225+
{
226+
limb: 'converged: non-string value on limb 1 (exact tag) ⇒ miss',
227+
label: { 'zh-CN': { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel,
228+
locale: 'zh-CN',
229+
pick: 'Sales',
230+
},
231+
{
232+
limb: 'converged: non-string value on limb 2 (base) ⇒ miss',
233+
label: { zh: { nested: 'x' }, en: 'Sales' } as unknown as I18nLabel,
234+
locale: 'zh-CN',
235+
pick: 'Sales',
236+
},
237+
{
238+
limb: 'converged: non-string value on limb 4 (default) ⇒ miss',
239+
label: { default: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel,
240+
locale: 'fr',
241+
pick: '営業',
242+
},
243+
{
244+
limb: 'converged: non-string value on limb 5 (en) ⇒ miss',
245+
label: { en: { nested: 'x' }, ja: '営業' } as unknown as I18nLabel,
246+
locale: 'fr',
247+
pick: '営業',
248+
},
249+
{
250+
limb: 'converged: a number value ⇒ miss',
251+
label: { en: 42, ja: '営業' } as unknown as I18nLabel,
252+
locale: 'fr',
253+
pick: '営業',
254+
},
255+
{
256+
limb: 'converged: a null value ⇒ miss',
257+
label: { en: null, ja: '営業' } as unknown as I18nLabel,
258+
locale: 'fr',
259+
pick: '営業',
260+
},
261+
{
262+
limb: 'converged: nothing usable anywhere ⇒ a genuine miss',
263+
label: { en: { nested: 'x' } } as unknown as I18nLabel,
264+
locale: 'fr',
265+
pick: '',
266+
},
267+
{
268+
limb: 'converged: an empty string value still HITS and stops the chain',
269+
label: { en: '', 'zh-CN': '销售' },
270+
locale: 'en',
271+
pick: '',
272+
},
165273
];
166274

167275
describe('resolveI18nLabel — rule parity with objectui pickLocalized (#6765 / #6761 ruling B)', () => {
@@ -223,47 +331,50 @@ describe('resolveI18nLabel — the producer-facing return shape', () => {
223331
});
224332
});
225333

226-
describe('resolveI18nLabel — the two deliberate departures from the reference', () => {
227-
// Both are documented on the resolver's module doc. They are pinned here with
228-
// BOTH answers so the divergence stays MEASURED: if a later change makes the
229-
// two agree again, these tests go red and say so, rather than quietly
230-
// becoming decoration.
231-
232-
it('reads own properties only — a locale naming an Object.prototype member is a miss', () => {
334+
describe('resolveI18nLabel — the rule departures converged with objectui#3907; one departure survives', () => {
335+
// Before objectui#3907 (landed as objectui PR #4359) this module's rule
336+
// documented two DELIBERATE narrowings of the reference. #4359 landed the
337+
// same two guards upstream, so the copied reference now answers identically
338+
// to `resolveI18nLabel` on both. Pinned here with BOTH sides so the
339+
// convergence stays MEASURED: if a later change on either side reopens the
340+
// gap, these tests go red and say so, rather than quietly rotting back into
341+
// decoration. See `PARITY_VECTORS`' "converged:" rows above for the fuller
342+
// 18-vector sweep mirrored from objectui's own parity suite.
343+
344+
it('reads own properties only, now on BOTH sides — a locale naming an Object.prototype member misses and falls through', () => {
233345
const label: I18nLabel = { en: 'Owner' };
234346

235-
// The reference resolves `map['constructor']` up the prototype chain and
236-
// renders the function's source text as the label. Filed as objectui#3907.
237-
expect(pickLocalizedReference(label, 'constructor')).toContain('function Object');
238-
239-
// Here it is simply not a key, so the chain continues to `en`. No BCP-47
240-
// tag is an `Object.prototype` member, so no in-contract input can tell the
241-
// two implementations apart — but on a server the locale can arrive in an
242-
// `Accept-Language` header, which is why this one is hardened.
347+
// Before objectui#3907 the reference resolved `map['constructor']` up the
348+
// prototype chain and rendered the function's source text as the label.
349+
// A guard makes its limb MISS; it does not abort — the chain now falls
350+
// through to `en` on both sides, converged by objectui PR #4359.
351+
expect(pickLocalizedReference(label, 'constructor')).toBe('Owner');
243352
expect(resolveI18nLabel(label, 'constructor')).toBe('Owner');
244353
expect(resolveI18nLabel(label, 'toString')).toBe('Owner');
245354
});
246355

247-
it('treats a non-string value as absent on EVERY limb, not just limbs 3 and 6', () => {
356+
it('treats a non-string value as absent on EVERY limb, now on BOTH sides', () => {
248357
// Off-spec: `InlineLocaleMapSchema` is `z.record(<tag>, z.string())`, so no
249358
// in-contract map can hold this. The cast is what makes that explicit.
250359
const offSpec = { 'zh-CN': { nested: 'x' }, en: 'Owner' } as unknown as I18nLabel;
251360

252-
// The reference filters by `typeof === 'string'` on limbs 3 and 6 but not
253-
// on 1/2/4/5, so an exact-tag hit short-circuits and gets stringified.
254-
expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('[object Object]');
255-
256-
// PD#12: the producer is wrong; the consumer must not coerce `[object
257-
// Object]` onto a screen. The filter is uniform, so the limb is a miss and
258-
// the chain continues.
361+
// Before objectui#3907 the reference filtered by `typeof === 'string'` on
362+
// limbs 3 and 6 but not on 1/2/4/5, so an exact-tag hit short-circuited
363+
// and got stringified as `[object Object]`. The filter is uniform on both
364+
// sides now, so the limb is a miss and the chain continues to `en`.
365+
expect(pickLocalizedReference(offSpec, 'zh-CN')).toBe('Owner');
259366
expect(resolveI18nLabel(offSpec, 'zh-CN')).toBe('Owner');
260367
});
261368

262-
it('refuses an off-contract scalar rather than stringifying it', () => {
263-
// `pickLocalized` accepts `unknown` and stringifies numbers/booleans. This
264-
// resolver's parameter is the declared `I18nLabel`, so the shapes below are
265-
// type errors — the `@ts-expect-error` directives immediately after are the
266-
// real guard. This asserts the runtime half: no coerced `'42'` label.
369+
it('refuses an off-contract scalar rather than stringifying it — the one departure objectui#3907 did NOT touch', () => {
370+
// `pickLocalized` accepts `unknown` and stringifies numbers/booleans
371+
// (`pickLocalized(42, 'en')` is `'42'`). This resolver's parameter is the
372+
// declared `I18nLabel`, so the shapes below are type errors — the
373+
// `@ts-expect-error` directives immediately after are the real guard.
374+
// This asserts the runtime half: no coerced `'42'` label. objectui#3907 /
375+
// PR objectui#4359 hardened the MAP limbs only; it never touched this
376+
// top-level VALUE-parameter case, so it is the one departure that
377+
// survives the sync (objectstack#7864).
267378
// @ts-expect-error a number is not an `I18nLabel` — off-spec input is refused, not coerced
268379
expect(resolveI18nLabel(42, 'en')).toBeUndefined();
269380
// @ts-expect-error a boolean is not an `I18nLabel`

packages/spec/src/ui/i18n-label-resolver.ts

Lines changed: 56 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@
3232
* client-rendered one simply differ.
3333
*
3434
* The reference is objectui `packages/i18n/src/pickLocalized.ts` (read at
35-
* `origin/main` `50fa376`, blob `9e5d92a`, last touched by objectui#3278). Its
36-
* rule, mirrored here limb for limb:
35+
* `origin/main` `d8d0d66`, blob `30fcb0a8`, last touched by objectui PR #4359 /
36+
* objectui#3907). Its rule, mirrored here limb for limb:
3737
*
3838
* | # | limb | note |
3939
* |---|---|---|
@@ -71,30 +71,60 @@
7171
* `i18n-label-resolver.test.ts`; changing it is a two-repo decision, not a
7272
* detail to fix in passing.
7373
*
74-
* ## Two deliberate departures, both narrower than the rule above
75-
*
76-
* 1. **Own properties only.** `pickLocalized` reads `map[locale]` with a bare
77-
* bracket access, so a locale that happens to name an `Object.prototype`
78-
* member (`constructor`, `toString`) resolves to that member and renders as
79-
* its source text. In a browser the locale comes from the app's own language
80-
* state; on a server it can come from an `Accept-Language` header, so this
81-
* module reads own properties only. No language tag is an `Object.prototype`
82-
* key, so no in-contract input can tell the two apart. Filed against the
83-
* reference as objectui#3907.
84-
* 2. **Only `string` values are eligible, on every limb.** `pickLocalized`
85-
* applies a `typeof === 'string'` filter on limbs 3 and 6 but not on 1, 2, 4,
86-
* 5, where a non-string value short-circuits the chain and is stringified
87-
* (`[object Object]`). `InlineLocaleMapSchema` declares `z.record(<tag>,
88-
* z.string())`, so no value that reaches either resolver in-contract is
89-
* anything but a string, and the inconsistency is unobservable inside the
90-
* declared domain. Out of contract, Prime Directive #12 says the producer is
91-
* wrong and the consumer must not coerce a rendered `[object Object]` onto a
92-
* screen — so the filter is applied uniformly and an off-spec value is
93-
* treated as absent.
94-
*
95-
* Both are stated rather than silent because parity, not taste, is what this
96-
* module is for: everything a caller can observe with an `I18nLabel` that
97-
* `I18nLabelSchema` accepts is identical between the two ends.
74+
* ## Rule departures — converged as of objectui#3907, zero remain
75+
*
76+
* This module shipped (objectstack#6765) with two deliberate narrowings of the
77+
* reference's limb rule, both stated rather than silent. objectui#3907 (landed
78+
* in objectui PR #4359) closed both upstream, so the two ends now agree limb
79+
* for limb on every map input:
80+
*
81+
* 1. **Own properties only.** Before objectui#3907, `pickLocalized` read
82+
* `map[locale]` with a bare bracket access, so a locale that happened to
83+
* name an `Object.prototype` member (`constructor`, `toString`) resolved to
84+
* that member and rendered as its source text. In a browser the locale
85+
* comes from the app's own language state; on a server it can come from an
86+
* `Accept-Language` header, so this module read own properties only from
87+
* the start. No language tag is an `Object.prototype` key, so no
88+
* in-contract input could ever tell the two apart — objectui#3907 landed
89+
* the same guard upstream regardless, closing the gap for out-of-contract
90+
* input too.
91+
* 2. **Only `string` values are eligible, on every limb.** Before
92+
* objectui#3907, `pickLocalized` applied a `typeof === 'string'` filter on
93+
* limbs 3 and 6 but not on 1, 2, 4, 5, where a non-string value
94+
* short-circuited the chain and was stringified (`[object Object]`).
95+
* `InlineLocaleMapSchema` declares `z.record(<tag>, z.string())`, so no
96+
* value that reaches either resolver in-contract is anything but a string,
97+
* and the inconsistency was unobservable inside the declared domain — but
98+
* Prime Directive #12 says the producer is wrong and the consumer must not
99+
* coerce a rendered `[object Object]` onto a screen, and objectui#3907
100+
* applies the filter uniformly upstream now too.
101+
*
102+
* A guard makes its limb **miss**; it does not abort the resolution — an
103+
* unusable entry falls through to the next limb exactly as an absent one does,
104+
* on both sides. `pickLocalized({ en: 'Pricing' }, 'constructor')` is now
105+
* `'Pricing'` (the chain falls through to the `en` limb), not `''` — `''` (or
106+
* this module's `undefined`) only when NO limb hits at all, e.g. an empty map.
107+
*
108+
* ## What still differs
109+
*
110+
* Two differences survive the sync, both stated rather than silent because
111+
* parity, not taste, is what this module is for — everything else a caller
112+
* can observe with an `I18nLabel` that `I18nLabelSchema` accepts is identical
113+
* between the two ends:
114+
*
115+
* * **The miss spelling.** `pickLocalized` answers a miss as `''`, because its
116+
* caller is a renderer writing into a text node. This module answers
117+
* `undefined` — see the return-shape note below. The two spellings are
118+
* bridged by one `?? ''`, pinned as an identity in the parity test.
119+
* * **The top-level scalar pass-through.** `pickLocalized` accepts `unknown`
120+
* and stringifies a bare number or boolean (`pickLocalized(42, 'en')` is
121+
* `'42'`). This module's parameter is the declared `I18nLabel`
122+
* (`string | Record<string, string>`), so a number or boolean is a type
123+
* error at the call site, not a value to coerce at runtime —
124+
* `resolveI18nLabel(42, 'en')` is `undefined`, refused rather than
125+
* stringified (Prime Directive #12). This is a departure in the VALUE
126+
* parameter, not a limb of the map rule above, and objectui#3907 / PR
127+
* objectui#4359 did not touch it — it is not expected to converge.
98128
*
99129
* ## Relation to the other resolver in this package
100130
*

0 commit comments

Comments
 (0)