Skip to content

fix(spec): translatePage resolves copy for components nested in properties.children - #13111

Merged
os-trump merged 2 commits into
mainfrom
claude/issue-12961-translate-nested-children
Aug 29, 2026
Merged

fix(spec): translatePage resolves copy for components nested in properties.children#13111
os-trump merged 2 commits into
mainfrom
claude/issue-12961-translate-nested-children

Conversation

@os-trump

Copy link
Copy Markdown
Collaborator

Fixes#12961

translatePage visited region-level components only, so copy authored under pages.PAGE.components.ID for a component nested in another component's properties.children parsed happily against the published face and was never applied — the accepted-but-inert shape. Measured downstream as four object-metric KPI labels staying English inside a fully translated page:card on hotCRM's zh-CN landing page.

The ruling this implements

Maintainer, 2026-08-29 (comment 5459559825 on #12961), verbatim 「同意」 — adopting option A:

translatePage descends into declaredproperties.children arrays, so copy authored for nested component ids is resolved; the documented region-only boundary (i18n-resolver.ts:944-946) is deliberately reversed by this ruling and its comment is rewritten to record the new contract.

Collision rule, fixed at ruling and to be pinned: region-level id wins; among nested matches, document-order first match. Recursion must be depth-guarded/cycle-safe (children are authored data). The published face does not change — this makes the resolver as wide as the face already is.

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.

  • No schema edit. The diff is three files: packages/spec/src/system/i18n-resolver.ts, its test file, and one patch changeset. packages/spec/src/system/translation.zod.ts — where pages.PAGE.components.ID is declared — is not in it. That declaration has ALWAYS accepted copy for a nested id; this PR does not widen or narrow what parses.
  • Generated artifacts unchanged, not regenerated-away.pnpm --filter @objectstack/spec build runs gen:schema, which rewrites the authorable-surface.base.json anchor; after the build git status --short is empty, so the regeneration reproduced the committed bytes exactly. check:generated then reports ✓ All 14 generated artifacts are up to date.
  • No public API surface added.check:api-surface reports @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 children and nothing else, which the tests pin as explicit negatives:

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:

  1. a region-level component carrying the id wins outright, including when its region comes after the nested match (pinned);
  2. otherwise the depth-first document-order first nested match takes it (pinned, including a deeper-earlier vs shallower-later case);
  3. region-level behaviour is untouched — every pre-existing translatePage test 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 testTest Files 442 passed (442) · Tests 11730 passed (11730). pnpm --filter @objectstack/spec typecheck (all three legs) exit 0; its ratchet leg prints check: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> returns T and 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.ts appears in bothtsconfig.json and tsconfig.test.json; i18n-resolver.test.ts appears in tsconfig.test.json only — the root program excludes tests, which is exactly what the check:test-typecheck leg covers.

Ablation on the ruled collision rule. Prediction written before the run: dropping the regionLevelIds half 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 being gives the entry to the REGION-LEVEL component when a nested id repeats it and wins region-level even when the region-level namesake comes LAST in a later region.

  • Mutation confirmed on disk, not from the editor's exit code: anchor count 1 → 0 and injected-text count 0 → 1, with the blob hash moving 0e2db5f3… → 9a0fdc92….
  • No rebuild leg is owed here, and this is a measurement rather than an omission: the test imports the subject as from './i18n-resolver' — a relative source path — so dist/ is not on the resolution path and a stale build cannot produce the silent green that an exports-resolved ablation risks.
  • Restore given the same treatment as the mutation: blob hash back to 0e2db5f3… (equal to the HEAD blob), git diff HEAD empty for the file, anchor counts back to 1 / 0. The script carried a trap … EXIT INT TERM with an absolute path derived from git 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, including check: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-wide pnpm 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:

familywhy no reading
check:type-check-debtrefuses outright: --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 is check: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.mjs invoked 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 keys translatePage now resolves are never scaffolded into the skeleton bundle #13109i18n-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 pair PAGE_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).
  • hotCRM's zh-CN bundle still needs 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

`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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json d48929efe794b467c169825ecb68a2d015f09d8dpackageMentionDocs.

Which tree this was computed on

This run read content/docs from c992246a580bf2889ccf303cb9f640af3580fba1 — the merge of head 5b21dca9f27a9f7527930740c2753f2724fcf29e into base d48929efe794b467c169825ecb68a2d015f09d8d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation protocol:system tests tooling labels Aug 29, 2026
@os-trumpos-trump added needs:contract-review and removed documentation Improvements or additions to documentation size/l tests tooling protocol:system labels Aug 29, 2026 — with Claude
@os-trump
os-trump marked this pull request as ready for review August 29, 2026 06:01
@os-trump
os-trump enabled auto-merge August 29, 2026 06:01
@os-trump
os-trump added this pull request to the merge queueAug 29, 2026
Merged via the queue into main with commit 901355cAug 29, 2026
56 checks passed
@os-trump
os-trump deleted the claude/issue-12961-translate-nested-children branch August 29, 2026 06:43
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants

@os-trump@claude