Uh oh!
There was an error while loading. Please reload this page.
fix(spec): translatePage resolves copy for components nested in properties.children - #13111
Conversation
`translatePage` visited region-level components only, so copy authored under `pages.<name>.components.<id>` for a component nested in another component's `properties.children` parsed against the published face and was never applied — the accepted-but-inert shape, measured as four KPI labels staying English inside a translated `page:card`. Ruled 2026-08-29 (option A): the resolver descends into declared `properties.children` arrays, reversing the documented region-only boundary and widening the resolver to the face already published. On an id collision a region-level component wins outright; among nested matches the document-order first takes the entry. The descent follows `children` only, and is depth-capped and cycle-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4
…s flat `translatePage<T extends PageLike>` returns `T`, so literal fixtures make reading back an overlaid key a type error. Widening the fixtures through one shared alias keeps packages/spec's test-typecheck debt at its recorded 11 for this file instead of growing it by 7. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpRNHxWZgSUgVnFT9mQQo4
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c992246a580bf2889ccf303cb9f640af3580fba1 && git checkout c992246a580bf2889ccf303cb9f640af3580fba1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d48929efe794b467c169825ecb68a2d015f09d8d 5b21dca9f27a9f7527930740c2753f2724fcf29e && git checkout -B drift-repro d48929efe794b467c169825ecb68a2d015f09d8d && git merge --no-ff 5b21dca9f27a9f7527930740c2753f2724fcf29e
node scripts/docs-audit/affected-docs.mjs --json d48929efe794b467c169825ecb68a2d015f09d8d |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12961
translatePagevisited region-level components only, so copy authored underpages.PAGE.components.IDfor a component nested in another component'sproperties.childrenparsed happily against the published face and was never applied — the accepted-but-inert shape. Measured downstream as fourobject-metricKPI labels staying English inside a fully translatedpage:cardon hotCRM's zh-CN landing page.The ruling this implements
Maintainer, 2026-08-29 (comment 5459559825 on #12961), verbatim 「同意」 — adopting option A:
All four terms are implemented and pinned: the descent, the collision rule (both directions), the depth guard and cycle safety, and the boundary comment rewritten — not deleted — to record the new contract and the ruling date.
Contract: the published face does not move
The resolver widens to the face; the face itself is untouched.
packages/spec/src/system/i18n-resolver.ts, its test file, and onepatchchangeset.packages/spec/src/system/translation.zod.ts— wherepages.PAGE.components.IDis declared — is not in it. That declaration has ALWAYS accepted copy for a nested id; this PR does not widen or narrow what parses.pnpm --filter @objectstack/spec buildrunsgen:schema, which rewrites theauthorable-surface.base.jsonanchor; after the buildgit status --shortis empty, so the regeneration reproduced the committed bytes exactly.check:generatedthen reports✓ All 14 generated artifacts are up to date.check:api-surfacereports@objectstack/spec public API surface + factory signatures unchanged ✓. The depth cap is a module-private constant deliberately, so the safety guard costs no exported surface; its pin restates the number in the test instead.The boundary the ruling drew, and where it stops
The descent follows the declared composition key
childrenand nothing else, which the tests pin as explicit negatives:body— the renderers readschema.children || schema.bodyas a back-compat fallback for stored documents, but SDUI props 声明与 renderer 不一致:6 处「renderer 兑现但 ComponentPropsMap 未声明」+ 2 处「声明了没人读」(#5068 error 升级的 spec 侧前置) #5775 settled thatbodyis not an authorable spelling ("one composition key, not two"). Descending it would resurrect the second spelling.properties.items[].children(page:tabs,page:accordion) — one level deeper than the slot the ruling names. Recorded as a pin rather than silently crossed.pages.PAGE.title/.subtitleaddress THE page's header; apage:headernested inside a container is not it, so nested components are reached by the id route only.Collision rule
Level-priority, then document order — deliberately not pure document order, since a nested child is visited before a later region-level sibling in a depth-first walk:
translatePagetest passes unmodified.Verification
Union run at
5b21dca9f, the final commit.Reproduce first, then green. The new block was written against the unchanged resolver: 7 failed / 11 passed, the KPI case reading
expected 'Revenue (Won)' to be '已赢收入'. After the implementation:Test Files 1 passed (1) · Tests 179 passed (179).Suite + typecheck.
pnpm --filter @objectstack/spec test→Test Files 442 passed (442) · Tests 11730 passed (11730).pnpm --filter @objectstack/spec typecheck(all three legs) exit 0; its ratchet leg printscheck:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; 55 file(s) / 263 error(s) held in test-typecheck-debt.json (shrink-only).That leg caught a real regression mid-run and is why there is a second commit: the first draft of the fixtures grew this file's recorded debt from 11 to 18. Fixed at the source (fixtures typed through one shared open alias, since
translatePage<T extends PageLike>returnsTand a literal fixture makes reading an overlaid key a type error), never by raising the ledger — the file is back to exactly its recorded 11, all above line 1100 and none in the new block.Coverage proof (
tsc --listFiles, so "typecheck is clean" is not a claim about files it never read):i18n-resolver.tsappears in bothtsconfig.jsonandtsconfig.test.json;i18n-resolver.test.tsappears intsconfig.test.jsononly — the root program excludes tests, which is exactly what thecheck:test-typecheckleg covers.Ablation on the ruled collision rule. Prediction written before the run: dropping the
regionLevelIdshalf reds the two region-level-wins tests and leaves both document-order tests green. Observed: exactly that —Tests 2 failed | 177 passed (179), the two failures beinggives the entry to the REGION-LEVEL component when a nested id repeats itandwins region-level even when the region-level namesake comes LAST in a later region.1 → 0and injected-text count0 → 1, with the blob hash moving0e2db5f3… → 9a0fdc92….from './i18n-resolver'— a relative source path — sodist/is not on the resolution path and a stale build cannot produce the silent green that anexports-resolved ablation risks.0e2db5f3…(equal to the HEAD blob),git diff HEADempty for the file, anchor counts back to1 / 0. The script carried atrap … EXIT INT TERMwith an absolute path derived fromgit rev-parse --show-toplevel.Gate family derived mechanically from the diff (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads the change set from the merge base itself), not from a hand-written list. 34 of the 38 matched families plus all 6 convention-triggered ones ran green, includingcheck:authorable-surface,check:generated,check:api-surface,check:docs,check:liveness,check:strictness-ledger,check:page-declaration-shape,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:nul-bytes. Repo-widepnpm lint(eslint . --no-inline-config) also ran green, so no narrowing is being claimed for it.Four families are NOT MEASURED — each exited on a stated prerequisite, which is not a pass and not a failure. CI runs all four:
check:type-check-debt--re-measure cannot run: 52 workspace dependencies … have no built type entry point on disk. Needs the whole workspace closure built. The package-level half my diff can actually move ischeck:test-typecheck, run green above.check:dual-build-cjs-loadsPREREQUISITE NOT MET — this gate reads built output, and some package has no dist/. ⛔ This is NOT a pass: nothing was measured.check-dev-prereqs.mjs✗ The workspace is not built — 1 unmet precondition, not a list of problems.check-half-states.mjsPREREQUISITE NOT MET — the token in the environment is not a valid GitHub credential(exit 3). Unrelated to this diff.check-test-completeness.mjsinvoked bare prints its usage line — it takes a turbo test log CI supplies — so it is likewise no reading.Out of scope, filed
i18n-extract's per-component pass is still region-level only, so the nested-children keystranslatePagenow resolves are never scaffolded into the skeleton bundle #13109 —i18n-extract's per-component pass is still region-level only, so the nested keys this PR now resolves are never scaffolded into the skeleton bundle. This is the other half of the drift pairPAGE_COMPONENT_COPY_KEYS's own JSDoc warns about; the shared key list means the two sides cannot drift on which keys, but the two walks are separate and now disagree on which components. Filed unassigned,Blocked-by:this card, deliberately not fixed here (different package, and how wide the extractor's walk should be is its own contract call).kpi_*entries before the four landing-page labels render translated — hotcrm#1004's follow-through, as the ruling notes, not this PR's.Generated by Claude Code