feat(spec): resolveI18nLabel — the shared I18nLabel to string resolver, pinned to objectui's pickLocalized - #6901
Merged
Conversation
…lver (#6765) `I18nLabelSchema` has authorized two forms of a display label since #5728: a plain string, and an inline locale map. Only ONE end of the platform knew what the second form means — objectui's `pickLocalized`. Every backend producer that had to put a label on the wire tested `typeof label === 'string'` and dropped anything else, so a dataset declaring its dimension label the way the schema authorizes shipped `fields[]` entries with no label at all (#6761's measurements). This adds the missing half in `packages/spec` rather than inside the service that needed it first (maintainer ruling 2026-08-08, #6761 option B): the backend had zero inline-map resolvers, and a first one born as a private fork is what the next producer copies (PD#12). Rule parity with `pickLocalized` is the contract and it is EXECUTED, not asserted: a 26-row vector table is checked against a pinned verbatim copy of the reference implementation first, then against this resolver. The only visible difference is the spelling of a miss — `undefined` here, `''` there — bridged by one `??` and pinned as an identity. Consumption (`AnalyticsService.queryDataset`, `dataset-compiler.ts`) is #6761 and is deliberately not touched here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…xport `resolveI18nLabel (function)` on `./ui`. 0 breaking (nothing removed or narrowed), 1 added — the delta `check:api-surface` asked for. Regenerated only after a post-rebase `pnpm --filter @objectstack/spec build`: run against the pre-rebase dist it also DROPPED `AuthoredRowWriteOperation` / `AuthoredRowWriteVerdict` from `contracts.json`, which #6841 had added to the source in the meantime — the AGENTS.md §9 stale-artefact trap, in mirror image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011sGk4SKHqGRgmmqUok1P8M
os-zhuang
marked this pull request as ready for review
August 9, 2026 04:19
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6765
Contract half of the #6761 ruling: one shared
I18nLabeltostringresolver inpackages/spec, pinned limb for limb to objectui'spickLocalizedby an executed parity table. The consumption half (AnalyticsService.queryDataset's two enrichment sites anddataset-compiler.ts:374/406) is #6761 and is not touched here — nopackages/servicesfile is in this diff.The ruling this implements (maintainer, 2026-08-08, on #6761), quoted verbatim and untranslated:
Premise, verified before implementing
The card's premise is that the backend has no
I18nLabeltostringresolver today. Confirmed onorigin/mainby declaration-form grep overpackages/spec— the only resolver-shaped declaration anywhere inpackages/isservice-analytics/src/dataset-executor.ts:952'sthis.orderLabels.resolveLabels(...), which resolves dimension row values against a bundle, a different mechanism.I18nLabelSchemaexists inpackages/spec/src/ui/i18n.zod.ts:164as #5728 left it. Premise holds.The rule extracted from
pickLocalizedRead at objectui
origin/main50fa3766ebb2ebf2ec78c5d13b1d627e6a91696f, filepackages/i18n/src/pickLocalized.ts, blob9e5d92ae2efe9be62d4d010cb0a26e598211f3ec, last touched by objectui#3278. Line numbers are that file's.Fallback chain — a six-limb
??chain at:26-32, in this order:''included:17o[lang]:27o[base](zh-CNreads the keyzh):28zhreads the keyzh-CN), inObject.keysinsertion order, string values only:25,:29o.default:30o.en:31Object.valuesorder:32Locale normalization:
(language || 'en').trim()at:21— a nullish or empty locale becomesen; surrounding whitespace is trimmed.baseislang.split('-')[0]at:22.Region / case handling: comparisons are case-sensitive throughout; neither the locale nor the keys are folded. The observable effect is asymmetric between the two halves of a tag, and both halves matter to a caller:
zh-CNanswers azh-cnrequest through limb 3;ZH-CNhas baseZH, which equals no key of that map on limbs 1, 2 or 3, so the request falls through todefault/en/ any-string and can land in the wrong language entirely.BCP-47 says subtags are case-insensitive, so a stricter reading would fold case. It is not folded here: parity is the ruled acceptance, and a resolver that folded case would answer differently from the renderer for exactly the inputs where it "improved". Both halves are pinned as vectors; changing it is a two-repo decision.
Both-miss return:
'', viapick == null ? '' : String(pick)at:33. Neverundefined, never a throw.Two things in the reference are surprising enough to state rather than inherit silently, both handled below under "deliberate departures": the
typeof === 'string'filter is applied on limbs 3 and 6 only (:25,:32) and not on 1/2/4/5; ando[lang]/o[base]are bare bracket accesses, so they walk the prototype chain.API shape — the trade-offs the card delegates
Candidate in the card:
resolveI18nLabel(label: I18nLabel | undefined, locale: string, fallback?: string): string | undefined. What shipped:Name.
resolveI18nLabel, as proposed — it joins theresolveViewLabel/resolveActionLabelfamily already insystem/i18n-resolver.ts. Deliberately notpickLocalized: an identically-named function whose miss value differs would be a trap, and the difference is exactly what a reader would assume away.Where it lives.
packages/spec/src/ui/i18n-label-resolver.ts, exported from@objectstack/spec/ui. The card allows either side;ui/won on three measured points: the input typeI18nLabelis declared one file away inui/i18n.zod.ts, so no cross-domain import; the #6761 consumer already imports its label-carrying types (Dataset,DatasetMeasure,DatasetDimension) from@objectstack/spec/ui(service-analytics/src/dataset-compiler.ts:5), so consumption adds no new subpath; andsystem/i18n-resolver.tsresolves the other form (a plain-string label plus a translation bundle), so they are siblings, not the same file. Both files gained a cross-reference to the other, andI18nLabelSchema's own doc now names the resolver that gives its second form meaning.undefinedvs''on a miss. This is the one place the function does not spell the outcome the reference's way, and it is deliberate.pickLocalizedreturns''because its caller writes into a text node. This resolver's callers are producers filling alabel?: stringfield, and downstream enrichment in that direction is guarded byif (field.label == null)— so a producer that wrote''would not be saying "no label", it would be permanently displacing a real label a later stage still had. That is #5199 route A, judged harmful rather than redundant, and restated as a hard boundary in #6761. Soundefinedhere means precisely what''means there, in the spelling each side's callers need, and the bridge is one??, pinned as an identity in the test:No
fallbackparameter.resolveI18nLabel(l, loc, f)andresolveI18nLabel(l, loc) ?? fare the same expression, so the parameter is pure redundancy — and the??spelling keeps the decision about the miss case visible in the file where writing the wrong thing does the damage. It also removes the one call the parameter invites,resolveI18nLabel(x, loc, ''), which silently reintroduces the placeholder harm above. Judged on the three axes: no measured business need the??does not already serve; one shape rather than two; and the shape that is harder to get wrong.localeis positional, not optional. It acceptsundefined(and resolves asen, matching the reference), but it cannot be omitted — a producer shipping one audience's language to every audience by forgetting an argument is the defect class #6761 records. A@ts-expect-errorpins that the omitting call does not compile.Input is the declared
I18nLabel, notunknown.pickLocalizedacceptsunknownand stringifies numbers and booleans. Under PD#12 that tolerance belongs at an untrusted boundary, not on an internal contract, so an off-spec scalar takes the miss path instead of being coerced into a visible label.Two deliberate departures, both narrower than the rule, both pinned with BOTH answers
o[lang]/o[base]bare, so a locale that names anObject.prototypemember resolves to that member:pickLocalized({ en: 'Pricing' }, 'constructor')returns'function Object() { [native code] }'. In a browser the locale comes from the app's own language state; on a server it can arrive in anAccept-Languageheader. No BCP-47 tag is anObject.prototypekey, so no in-contract input can tell the two implementations apart. Filed against the reference as objectui#3907.stringfilter applies on every limb. The reference filters on limbs 3 and 6 only, so a non-string value on limbs 1/2/4/5 short-circuits and renders as[object Object].InlineLocaleMapSchemais a record of locale tags toz.string(), so no in-contract map can hold a non-string and the inconsistency is unobservable inside the declared domain; out of contract, PD#12 says refuse rather than coerce.Both are asserted in the test with the reference's differing answer alongside, so the divergence stays measured rather than becoming decoration.
Parity pin
packages/spec/src/ui/i18n-label-resolver.test.tscarries a verbatim copy ofpickLocalizedaspickLocalizedReference, pinned to the revision above. Copied rather than imported because@objectstack/specmust not take a workspace dependency on objectui — spec sits under objectui in the dependency order. The copy is what makes each expectation a measurement instead of the author's recollection: every one of the 26 vectors is asserted against the reference first, then againstresolveI18nLabel, then both are compared in one pass. It is not exported and nothing undersrc/imports it.Reverse verification — three directions, each predicted before it was run
Taken out with a file copy restored by a trap, never
git stash(sharedrefs/stash). Finaldiffagainst the pre-experiment copy: identical.(a) Delete limb 3 (the region upgrade). Predicted RED — and red it went:
Tests 4 failed | 59 passed (63).Worth recording that only 3 of the 5 limb-3 vectors discriminate: the two-region map at
zhand thezh-CN-only map atzh-cnstay green without limb 3, because limb 6 happens to return the same string. They are kept — they pin the ordering between limbs — but they are not what makes limb 3 falsifiable, and a reader should not think they are.(b) Return
''on a miss instead ofundefined. Predicted INVERTED, and confirmed: the entire 26-row parity table stayed green —?? ''collapses exactly this difference — while 5 assertions in the producer-facing suites went red:This is the useful finding of the exercise: the parity table alone cannot catch a miss-shape regression. The two suites are not redundant — one pins which entry is chosen, the other pins how "no entry" is spelled — and a reviewer reading only the parity table would over-trust it.
(c) Type level: make
localeoptional. Predicted: the@ts-expect-errorguarding the forgot-the-locale call becomes unused, so tsc reports it. Run againsttsconfig.test.json, which really does compile this file (check:test-typecheckruns it), so the directive is not a phantom.Verification
All commands run in a dedicated worktree on the post-rebase tree, heavy phases serialized on the shared lock.
pnpm --filter @objectstack/spec build— PASS (first command in the fresh worktree, and again after the rebase; see the api-surface note below for why that ordering is load-bearing)pnpm --filter @objectstack/spec test— PASS, full suite; the new file alone isTest Files 1 passed (1) / Tests 63 passed (63)pnpm --filter @objectstack/spec typecheck— PASS (tsc --noEmit+check:scripts-typecheck+check:test-typecheck: "OK — the test layer compiles; 58 file(s) / 266 error(s) held in test-typecheck-debt.json", unchanged; the new test file is not in that ledger because it has no errors)pnpm lint— PASS.github/workflows/lint.yml, run one by one — all PASS. The ESLint job's 33 static gates (check:slot-lookup…check:spec-parsed-alias), and the type-check job's spec steps (check:authorable-surface,check:docs,check:skill-refs,check:spec-changes,check:upgrade-guide,check:generated --reconcile-only,check:exported-any,check:dual-source-exports,check:skill-examples,check:react-blocks,check:skill-docs), pluscheck:skill-frame-sync,check:skill-compatibility,check:type-check-coverage,check:driver-conformance,check:stall-guard,check:doc-formula-expressions,check:i18n,check:i18n-coverage,check:app-nav-i18n.turbo run build --filter='./packages/*' --filter='./packages/*/*' --filter='./examples/*^...'— 71/71 tasks; thenturbo run typecheck --filter='./packages/*' --filter='./packages/*/*' --filter='./apps/*'— 120/120 tasks. Plusexamples typecheckanddownstream-contract typecheck, both PASS.pnpm check:type-check-debt(the--re-measureratchet, run with the closure built) — PASS: "34 ledger entr(ies) re-measured, 1759 raw tsc error(s) total, none above its recorded number". No entry raised, so no note needed rewriting.check:api-surfacereports exactly the expected delta, and the regenerated baseline is committed:Worth flagging for the next author: regenerating before rebuilding after a rebase is actively wrong here. Run against a dist built from the pre-rebase source,
gen:api-surfacealso droppedAuthoredRowWriteOperation/AuthoredRowWriteVerdictfromcontracts.json— types #6841 had added to the source in the meantime. That is the AGENTS.md stale-artefact trap in mirror image: the generator readsdist, so a staledistsilently writes a baseline for a tree that no longer exists. Reverted, rebuilt, regenerated; the committed diff is one line.authorable-surface.base.json— no authorable key changed,check:authorable-surfaceis green, andgit statusis empty after the full closure build (no tracked-artefact drift of any kind).Scope
packages/speconly, plus its regenerated api-surface snapshot and one changeset (additive export, so minor). Nopackages/servicesfile is touched.Generated by Claude Code