From 30255479a4751c5fe272003ef710e7f805bd120f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 10:33:17 +0000 Subject: [PATCH 1/5] test(cli): differential walk-parity guard between i18n-extract and translatePage Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- .../test/platform-page-i18n-parity.test.ts | 300 +++++++++++++++++- 1 file changed, 299 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 6aa73bbb2a..1d3b393621 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -28,7 +28,7 @@ import { } from '@objectstack/cloud-connection'; import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; import { SetupAppTranslations } from '@objectstack/platform-objects'; -import { translatePage } from '@objectstack/spec/system'; +import { PAGE_COMPONENT_COPY_KEYS, translatePage } from '@objectstack/spec/system'; import { collectExpectedEntries } from '../src/utils/i18n-extract'; /** The pages exactly as the plugins register them with the kernel. */ @@ -138,3 +138,301 @@ describe('plugin-carried Setup pages — i18n drift guard (#3589)', () => { } }); }); + +// ─── Extractor ↔ resolver WALK parity (#13109) ───────────────────────────── +// +// The guard above compares extractor output against the SHIPPED bundle, so it +// only ever sees keys the extractor already emits — it is structurally blind +// to "a key that should have been offered and wasn't", which is exactly the +// defect #13109 records. This block is the differential the shared +// `PAGE_COMPONENT_COPY_KEYS` list cannot give: the KEY LIST has one definition +// and both sides import it, but the WALK — which COMPONENTS carry those keys — +// is written twice, once in `translatePage` (`packages/spec`) and once in +// `collectExpectedEntries`. `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names the +// failure pair a second hand-maintained copy produces: offering a key the +// resolver ignores, or omitting one it reads. These tests fail on BOTH halves. +// +// The instrument is deliberately not a restatement of either walk: it runs the +// real `translatePage` against a sentinel bundle and asks which components it +// ACTUALLY rewrote, then compares that set against the ids the real extractor +// ACTUALLY offered. A copy of the traversal in the test would drift with +// whichever side it was copied from and pass through the drift it exists to +// catch. +// +// ⚠️ `MAX_NESTED_COMPONENT_DEPTH` is deliberately NOT exported by +// `packages/spec`, so the extractor mirrors the number rather than importing +// it. The deep-chain case below is what keeps the mirror honest: it walks +// deeper than the cap and asserts the two sides agree about where the descent +// stops, so raising the cap on one side alone reds here. + +/** Every component record reachable anywhere in a page document, by id. */ +const titlesById = (node: unknown, out = new Map()): Map => { + if (Array.isArray(node)) { + for (const item of node) titlesById(item, out); + return out; + } + if (!node || typeof node !== 'object') return out; + const rec = node as Record; + const props = rec.properties; + if ( + typeof rec.id === 'string' && rec.id.length > 0 && + props && typeof props === 'object' && typeof props.title === 'string' + ) { + out.set(rec.id, props.title); + } + for (const value of Object.values(rec)) titlesById(value, out); + return out; +}; + +/** The sentinel a bundle entry carries, so an applied overlay is unmistakable. */ +const sentinel = (id: string): string => `SENTINEL::${id}`; + +/** + * Ids `translatePage` ACTUALLY rewrote — measured, not restated: a bundle that + * offers `pages.PAGE.components.ID.title` for EVERY id in the document, then a + * walk of the result for the ones that came back carrying the sentinel. + */ +const idsResolverApplies = (page: Record): Set => { + const ids = [...titlesById(page).keys()]; + const bundle = { + en: { + pages: { + [page.name]: { + components: Object.fromEntries(ids.map((id) => [id, { title: sentinel(id) }])), + }, + }, + }, + } as any; + const translated = titlesById(translatePage(page as any, bundle, { locale: 'en' })); + return new Set(ids.filter((id) => translated.get(id) === sentinel(id))); +}; + +/** Ids the extractor offers a `components.ID.title` key for. */ +const idsExtractorOffers = (page: Record): Set => + new Set( + collectExpectedEntries({ pages: [page] } as any) + .filter((e) => + e.path[0] === 'pages' && e.path[1] === page.name && + e.path[2] === 'components' && e.path[4] === 'title') + .map((e) => e.path[3]), + ); + +/** Entries the extractor offers under `pages.PAGE.components`, as flat rows. */ +const componentRows = (page: Record): Array<{ key: string; value?: string }> => + collectExpectedEntries({ pages: [page] } as any) + .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name && e.path[2] === 'components') + .map((e) => ({ key: e.path.slice(3).join('.'), value: e.sourceValue })); + +/** + * One page carrying every nesting shape that exists on this surface — the ones + * `translatePage` descends and the ones it deliberately does not. Built fresh + * per call because `translatePage` returns a new document and the fixtures are + * compared against their own source. + */ +const walkParityPage = (): Record => ({ + name: 'walk_parity_page', + regions: [ + { + name: 'top', + components: [ + // Region-level `page:header` WITH an id — see the exception below. + { id: 'hdr', type: 'page:header', properties: { title: 'Header title' } }, + ], + }, + { + name: 'main', + components: [ + { id: 'region_metric', type: 'object-metric', properties: { title: 'Region metric' } }, + { + id: 'card', + type: 'page:card', + properties: { + title: 'Card', + // DESCENDED — the one composition key the ruling names. + children: [ + { id: 'kpi_1', type: 'object-metric', properties: { title: 'KPI one' } }, + // `label` authored at top level rather than in props. + { id: 'kpi_label', type: 'object-metric', label: 'KPI two', properties: { title: 'KPI two title' } }, + { + id: 'inner_flex', + type: 'page:flex', + properties: { + title: 'Inner flex', + children: [ + { id: 'kpi_deep', type: 'object-metric', properties: { title: 'Deep KPI' } }, + // A nested `page:header` is reachable by the id route ONLY + // (the page-name route addresses THE page's header and + // stops at region level), so it must be offered here. + { id: 'nested_header', type: 'page:header', properties: { title: 'Nested header' } }, + ], + }, + }, + // `children` is `z.array(z.unknown())` — non-components are legal. + 'bare-component-id-string', + null, + ], + // NOT descended by `translatePage`: `body`/`footer` are a + // renderer-side back-compat fallback, and `items[].children` sits + // one level deeper than the slot the ruling names. + body: [{ id: 'card_body_child', type: 'object-metric', properties: { title: 'Body child' } }], + footer: [{ id: 'card_footer_child', type: 'object-metric', properties: { title: 'Footer child' } }], + items: [{ children: [{ id: 'tab_child', type: 'object-metric', properties: { title: 'Tab child' } }] }], + }, + }, + ], + }, + ], + // NOT walked by `translatePage` at all — it maps `regions` only. + slots: { aside: { id: 'slot_child', type: 'object-metric', properties: { title: 'Slot child' } } }, +}); + +/** A container chain deeper than the resolver's descent cap. */ +const deepChainPage = (length: number): Record => { + const node = (depth: number): Record => ({ + id: `d${depth}`, + type: 'page:flex', + properties: { + title: `Depth ${depth}`, + ...(depth + 1 < length ? { children: [node(depth + 1)] } : {}), + }, + }); + return { name: 'walk_depth_page', regions: [{ name: 'main', components: [node(0)] }] }; +}; + +/** Ids repeated across levels, so the ruled arbitration is observable. */ +const collisionPage = (): Record => ({ + name: 'walk_collision_page', + regions: [ + { + name: 'main', + components: [ + { id: 'shared', type: 'object-metric', properties: { title: 'Region level wins' } }, + { id: 'hdr_id', type: 'page:header', properties: { title: 'Header holds this id' } }, + { + id: 'wrap', + type: 'page:card', + properties: { + title: 'Wrap', + children: [ + { id: 'shared', type: 'object-metric', properties: { title: 'Nested namesake loses' } }, + { id: 'hdr_id', type: 'object-metric', properties: { title: 'Nested under a header id loses' } }, + { id: 'twice', type: 'object-metric', properties: { title: 'First nested wins' } }, + { id: 'twice', type: 'object-metric', properties: { title: 'Second nested loses' } }, + ], + }, + }, + ], + }, + ], +}); + +describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { + it('offers a per-component key for exactly the components the resolver rewrites', () => { + const page = walkParityPage(); + const offered = idsExtractorOffers(page); + const applied = idsResolverApplies(page); + + // Both directions, named separately so a failure says WHICH half broke. + expect({ offeredButIgnored: [...offered].filter((id) => !applied.has(id)).sort() }) + .toEqual({ offeredButIgnored: [] }); + // The ONE standing exception, pre-dating this card and deliberate: a + // region-level `page:header`'s copy is offered under `pages.PAGE.title` / + // `.subtitle` instead, because emitting it here too would offer one string + // under two keys. The resolver still honours the id route for it, so it + // shows up as applied-not-offered — listed explicitly rather than filtered + // out of the fixture, so the exception stays visible and bounded to one id. + expect({ appliedButNotOffered: [...applied].filter((id) => !offered.has(id)).sort() }) + .toEqual({ appliedButNotOffered: ['hdr'] }); + }); + + it('pins the two sets by name, so a shape that stops being reachable is visible', () => { + const page = walkParityPage(); + expect([...idsExtractorOffers(page)].sort()).toEqual([ + 'card', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', + ]); + // `card_body_child`, `card_footer_child`, `tab_child` and `slot_child` are + // absent from BOTH sides — the shapes `translatePage` does not descend. + expect([...idsResolverApplies(page)].sort()).toEqual([ + 'card', 'hdr', 'inner_flex', 'kpi_1', 'kpi_deep', 'kpi_label', 'nested_header', 'region_metric', + ]); + }); + + it('carries the whole shared key list down into nesting, label either/or included', () => { + const rows = componentRows(walkParityPage()); + // `label` authored at the component's top level, the same either/or + // `translatePage` resolves back onto. + expect(rows).toContainEqual({ key: 'kpi_label.label', value: 'KPI two' }); + expect(rows).toContainEqual({ key: 'kpi_deep.title', value: 'Deep KPI' }); + // Every offered key belongs to the shared list — the extractor must not + // invent a key the resolver has no reader for. + const keys = new Set(rows.map((r) => r.key.split('.').slice(1).join('.'))); + expect([...keys].filter((k) => !(PAGE_COMPONENT_COPY_KEYS as readonly string[]).includes(k))) + .toEqual([]); + }); + + it('stops descending where the resolver stops, on a chain deeper than the cap', () => { + const page = deepChainPage(40); + const offered = idsExtractorOffers(page); + const applied = idsResolverApplies(page); + expect([...offered].sort()).toEqual([...applied].sort()); + // Stated as a number so the mirrored cap is visible in the failure text; + // the set comparison above is what actually holds the two sides together. + const deepest = Math.max(...[...applied].map((id) => Number(id.slice(1)))); + expect({ deepest, offeredCount: offered.size }).toEqual({ deepest: 32, offeredCount: 33 }); + }); + + it('resolves a repeated id to one component, the same one the resolver picks', () => { + const page = collisionPage(); + const rows = componentRows(page); + + // One bundle entry, one component: no id may be offered twice. + const keys = rows.map((r) => r.key); + expect(keys.length).toEqual(new Set(keys).size); + + // Region level wins outright over a nested namesake. + expect(rows.filter((r) => r.key === 'shared.title')) + .toEqual([{ key: 'shared.title', value: 'Region level wins' }]); + // Among nested components, document order decides. + expect(rows.filter((r) => r.key === 'twice.title')) + .toEqual([{ key: 'twice.title', value: 'First nested wins' }]); + // A region-level `page:header` emits nothing here, but its id still BLOCKS + // a nested namesake — the resolver counts it as region-level, so offering + // the nested one would be a key the resolver ignores. + expect(rows.filter((r) => r.key.startsWith('hdr_id.'))).toEqual([]); + + // And the resolver agrees about which component the entry lands on. + const bundle = { + en: { + pages: { + walk_collision_page: { + components: { + shared: { title: 'S' }, twice: { title: 'T' }, hdr_id: { title: 'H' }, + }, + }, + }, + }, + } as any; + const translated = translatePage(page as any, bundle, { locale: 'en' }); + const region = translated.regions[0].components; + expect(region[0].properties.title).toEqual('S'); + expect(region[1].properties.title).toEqual('H'); + const nested = region[2].properties.children; + expect(nested.map((c: any) => c.properties.title)).toEqual([ + 'Nested namesake loses', + 'Nested under a header id loses', + 'T', + 'Second nested loses', + ]); + }); + + it('leaves a component that carries no descent slot untouched', () => { + // A container whose `children` is absent must not gain an invented + // `properties` bag, and a cyclic document must not hang the extractor. + const cyclic: Record = { + id: 'loop', type: 'page:flex', properties: { title: 'Loop', children: [] as unknown[] }, + }; + cyclic.properties.children.push(cyclic); + const page = { name: 'walk_cycle_page', regions: [{ name: 'main', components: [cyclic] }] }; + expect(componentRows(page)).toEqual([{ key: 'loop.title', value: 'Loop' }]); + }); +}); From 354df21b0f9a810e4cd510e1087c4078956f6836 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 10:39:37 +0000 Subject: [PATCH 2/5] fix(cli): match i18n-extract's per-component walk to translatePage's Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- packages/cli/src/utils/i18n-extract.ts | 156 +++++++++++++++--- .../test/platform-page-i18n-parity.test.ts | 49 +++++- 2 files changed, 173 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index b15473bb0d..c920cb2a26 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -703,6 +703,132 @@ export function authorWarnedTranslationGroups(): ReadonlySet { return new Set([...warned].filter((path) => !path.includes('.'))); } +/** + * How many levels of `properties.children` nesting the per-component pass + * descends below region level — a MIRROR of `translatePage`'s + * `MAX_NESTED_COMPONENT_DEPTH` (#12961), which `@objectstack/spec` deliberately + * does not export ("the guard is a safety property of the walk, not a contract + * consumers address"). + * + * ⚠️ A mirrored constant drifts, so it is not left to care: the deep-chain case + * in `test/platform-page-i18n-parity.test.ts` runs a container chain LONGER + * than this number through both sides and asserts they stop at the same depth, + * so raising the cap in `packages/spec` alone reds there rather than silently + * making the extractor the narrower of the two again. The durable fix is a walk + * exported from `packages/spec` and imported by both sides, the way + * `PAGE_COMPONENT_COPY_KEYS` already is — filed separately, since it is a + * `packages/spec` edit. + */ +const MAX_NESTED_COMPONENT_DEPTH = 32; + +/** + * Write `pages..components..` for every component + * `translatePage` addresses — and only those (#13109). + * + * The KEY list is shared ({@link PAGE_COMPONENT_COPY_KEYS}, imported from the + * resolver), so neither side can drift on WHICH KEYS. The WALK is not shared, + * and that is the drift this function exists to close: the resolver descends a + * container's declared `properties.children` recursively, so + * `pages..components.` resolves for a nested component id, while this + * pass used to iterate `regions[].components[]` and stop — the second half of + * the failure pair `PAGE_COMPONENT_COPY_KEYS`' own JSDoc names (the extractor + * OMITTING a key the resolver reads). A translator extracting a page whose copy + * lives in nested components got a skeleton with no entries for them. + * + * ⛔ MATCHED, not widened. `@objectstack/lint`'s `walkPageComponents` is the + * shared traversal one pass down (object sections), and reusing it here would + * have been the cheaper edit — but it is WIDER than the resolver in four ways + * (`slots.` roots, `properties.items[].children`, `properties.body`, + * `properties.footer`) and NARROWER in one (it skips `kind: 'html' | 'react' | + * 'jsx'` pages, which `translatePage` walks). Emitting its extra shapes would + * recreate the OTHER half of the same failure pair — offering keys the resolver + * ignores — and adopting its page skip would drop region-level keys that + * resolve today. So this walk mirrors `translatePage` exactly: + * + * - roots: `regions[].components[]` only — `slots.` is not walked by + * the resolver, on any page; + * - descent: `properties.children` only, recursively, depth-capped at + * {@link MAX_NESTED_COMPONENT_DEPTH} and cycle-guarded on ancestors, + * because `children` is `z.array(z.unknown())` authored data; + * - collisions: a region-level component holding an id wins outright — even + * a `page:header`, which emits nothing here but still BLOCKS a nested + * namesake — and among nested components the document-order first match + * takes the entry. One bundle entry, one component, so a repeated id can + * never be counted twice against coverage either. + */ +function emitPageComponentCopy(out: ExpectedEntry[], page: any, name: string): void { + const regions: any[] = Array.isArray(page.regions) ? page.regions : []; + const regionComponents = (region: any): any[] => + Array.isArray(region?.components) ? region.components : []; + + // Pass 1: every id carried at REGION level, known in full before the descent + // visits its first nested component — a region-level namesake in a LATER + // region still beats a nested match seen earlier. + const regionLevelIds = new Set(); + for (const region of regions) { + for (const component of regionComponents(region)) { + const id = componentId(component); + if (id !== undefined) regionLevelIds.add(id); + } + } + + // Ids already taken by an earlier nested component, so the document-order + // first match keeps the entry. + const claimedNestedIds = new Set(); + // The current descent path, for the cycle guard. Ancestors only — a subtree + // referenced twice as a SIBLING is two legitimate components. + const ancestors = new Set(); + + const visit = (component: any, depth: number): void => { + if (!component || typeof component !== 'object' || Array.isArray(component)) return; + if (ancestors.has(component)) return; + + const nested = depth > 0; + const id = componentId(component); + if (id !== undefined && (!nested || (!regionLevelIds.has(id) && !claimedNestedIds.has(id)))) { + if (nested) claimedNestedIds.add(id); + if (nested || component.type !== PAGE_HEADER_COMPONENT_TYPE) { + const props = component.properties ?? {}; + for (const key of PAGE_COMPONENT_COPY_KEYS) { + // `label` may be authored on the component itself or in its props — + // the same either/or `translatePage` resolves back onto. + const value = key === 'label' && typeof component.label === 'string' && component.label + ? component.label + : props[key]; + if (typeof value === 'string' && value) { + pushEntry(out, ['pages', name, 'components', id, key], value, 'page'); + } + } + } + } + + if (depth >= MAX_NESTED_COMPONENT_DEPTH) return; + const props = component.properties; + if (!props || typeof props !== 'object' || Array.isArray(props)) return; + const children = (props as Record).children; + if (!Array.isArray(children)) return; + ancestors.add(component); + try { + for (const child of children) visit(child, depth + 1); + } finally { + ancestors.delete(component); + } + }; + + for (const region of regions) { + for (const component of regionComponents(region)) visit(component, 0); + } +} + +/** The id a component is addressed by, or `undefined` when it carries none. */ +function componentId(component: any): string | undefined { + if (!component || typeof component !== 'object') return undefined; + return typeof component.id === 'string' && component.id.length > 0 ? component.id : undefined; +} + +/** The one component type whose copy is addressed by PAGE name, not by id. */ +const PAGE_HEADER_COMPONENT_TYPE = 'page:header'; + /** Options shared by the two surfaces built on {@link collectExpectedEntries}. */ export interface ExpectedEntryOptions { /** @@ -941,7 +1067,7 @@ export function collectExpectedEntries( for (const region of regions) { const components: any[] = Array.isArray(region?.components) ? region.components : []; for (const component of components) { - if (component?.type !== 'page:header') continue; + if (component?.type !== PAGE_HEADER_COMPONENT_TYPE) continue; const props = component.properties ?? {}; // `title` duplicating `label` is the common case and resolves via the // label fallback — only emit it when the two genuinely differ. @@ -959,28 +1085,12 @@ export function collectExpectedEntries( // translator would have to know the keys to hand-write them — which is // most of the reason the copy went untranslated in the first place. // - // `page:header` is deliberately skipped: its copy is addressed by page - // name above, and emitting it here too would offer one string under two - // keys. - for (const region of regions) { - const components: any[] = Array.isArray(region?.components) ? region.components : []; - for (const component of components) { - if (component?.type === 'page:header') continue; - const id = component?.id; - if (typeof id !== 'string' || !id) continue; - const props = component.properties ?? {}; - for (const key of PAGE_COMPONENT_COPY_KEYS) { - // `label` may be authored on the component itself or in its props — - // the same either/or `translatePage` resolves back onto. - const value = key === 'label' && typeof component.label === 'string' && component.label - ? component.label - : props[key]; - if (typeof value === 'string' && value) { - pushEntry(out, ['pages', name, 'components', id, key], value, 'page'); - } - } - } - } + // `page:header` is deliberately skipped AT REGION LEVEL: its copy is + // addressed by page name above, and emitting it here too would offer one + // string under two keys. A NESTED `page:header` is a different component — + // `translatePage`'s page-name route stops at region level, so a nested one + // is reachable by the id route ONLY and must be offered here (#13109). + emitPageComponentCopy(out, page, name); } // ── Screen flows (`flows..screens..…`, #7646 / #11287) ─ diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 1d3b393621..c1ac79e4d6 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -165,13 +165,24 @@ describe('plugin-carried Setup pages — i18n drift guard (#3589)', () => { // deeper than the cap and asserts the two sides agree about where the descent // stops, so raising the cap on one side alone reds here. -/** Every component record reachable anywhere in a page document, by id. */ -const titlesById = (node: unknown, out = new Map()): Map => { +/** + * Every component record reachable anywhere in a page document, by id — a + * generic JSON walk, deliberately NOT a copy of either side's traversal, so it + * cannot drift with the walk it is measuring. `seen` is its own cycle guard: + * one fixture below is self-referential. + */ +const titlesById = ( + node: unknown, + out = new Map(), + seen = new Set(), +): Map => { + if (!node || typeof node !== 'object') return out; + if (seen.has(node)) return out; + seen.add(node); if (Array.isArray(node)) { - for (const item of node) titlesById(item, out); + for (const item of node) titlesById(item, out, seen); return out; } - if (!node || typeof node !== 'object') return out; const rec = node as Record; const props = rec.properties; if ( @@ -180,7 +191,7 @@ const titlesById = (node: unknown, out = new Map()): Map { ]); }); - it('leaves a component that carries no descent slot untouched', () => { - // A container whose `children` is absent must not gain an invented - // `properties` bag, and a cyclic document must not hang the extractor. + it('terminates on a self-referential `children` array', () => { + // `children` is `z.array(z.unknown())` authored data, so the resolver + // carries an ancestor-path cycle guard and this walk mirrors it. + // + // ⚠️ `kind: 'html'` is load-bearing, not decoration. The object-sections + // pass inside the SAME `collectExpectedEntries` call reaches this page + // through `@objectstack/lint`'s `walkPageComponents`, which has no cycle + // guard and blows the stack on this fixture (RangeError, measured) — filed + // separately, out of scope here. `walkPageComponents` skips source-authored + // pages, while `translatePage` walks their regions like any other page, so + // this kind isolates the pass under test. That divergence is itself part of + // why this walk is NOT `walkPageComponents`. const cyclic: Record = { id: 'loop', type: 'page:flex', properties: { title: 'Loop', children: [] as unknown[] }, }; cyclic.properties.children.push(cyclic); - const page = { name: 'walk_cycle_page', regions: [{ name: 'main', components: [cyclic] }] }; + const page = { + name: 'walk_cycle_page', kind: 'html', + regions: [{ name: 'main', components: [cyclic] }], + }; expect(componentRows(page)).toEqual([{ key: 'loop.title', value: 'Loop' }]); + // The resolver terminates on the same document too, and still applies the + // entry — read directly rather than through the generic walk above, whose + // by-id map cannot express "the same id twice, one translated". + const bundle = { + en: { pages: { walk_cycle_page: { components: { loop: { title: 'L' } } } } }, + } as any; + const translated = translatePage(page as any, bundle, { locale: 'en' }) as any; + expect(translated.regions[0].components[0].properties.title).toEqual('L'); }); }); From 0f44443da562b41dda52bfa00b45d33bb6824290 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 11:02:45 +0000 Subject: [PATCH 3/5] i18n(example-showcase): translate the nested component labels the extractor now offers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- .../src/system/translations/index.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index e1d96286fc..d924add710 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -893,6 +893,30 @@ export const ShowcaseTranslationBundle = { project_picker: { label: '项目', placeholder: '选择项目…' }, }, }, + // Nested per-component copy. These ids sit inside a container's + // `properties.children`, which `translatePage` resolves (#12961) but + // `os i18n extract` could not see until #13109 matched the extractor's + // walk to the resolver's — so they were declared surface that the + // ratchet had never counted. Nothing about the AUTHORED metadata changed; + // the extractor stopped omitting it. + showcase_command_center: { + components: { + cc_k1: { label: '活跃项目' }, + cc_k2: { label: '待办任务' }, + cc_k3: { label: '待复审' }, + cc_k4: { label: '风险项目' }, + cc_k5: { label: '客户' }, + cc_k6: { label: '总预算' }, + }, + }, + showcase_styling_gallery: { + components: { + cta_Free: { label: '开始使用' }, + cta_Solo: { label: '升级到 Solo' }, + cta_Team: { label: '升级到 Team' }, + cta_Business: { label: '升级到 Business' }, + }, + }, }, }, }; From b8e095f43fdb6dceb033f02439067c69b47dddfb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 11:04:02 +0000 Subject: [PATCH 4/5] chore: changeset for the i18n-extract nested-children walk Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- .../i18n-extract-nested-component-walk.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/i18n-extract-nested-component-walk.md diff --git a/.changeset/i18n-extract-nested-component-walk.md b/.changeset/i18n-extract-nested-component-walk.md new file mode 100644 index 0000000000..e77ee0fb08 --- /dev/null +++ b/.changeset/i18n-extract-nested-component-walk.md @@ -0,0 +1,33 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): match `i18n-extract`'s per-component walk to `translatePage`'s (#13109) + +`pages.PAGE.components.ID.KEY` resolves for a component nested in a +container's declared `properties.children` (the resolver was widened to the +face it already served). `collectExpectedEntries` — behind `os i18n extract` +and `os i18n coverage` — still iterated `regions[].components[]` and stopped, +so the extractor OMITTED keys the resolver reads: a translator extracting a +page whose copy lives in nested components got a skeleton with no entries for +them and had to know the keys to hand-write them. + +`PAGE_COMPONENT_COPY_KEYS`' JSDoc names that failure pair, and the shared key +list closes only half of it: the KEY list has one definition both sides +import, while the WALK is written twice. This matches the second half, and +matches it EXACTLY rather than widening — roots `regions[].components[]` only, +descent `properties.children` only, same depth cap, same cycle guard, same +ruled collision arbitration (a region-level id wins outright; among nested +components document order decides). `@objectstack/lint`'s `walkPageComponents` +was the cheaper reuse and is deliberately not used here: it is wider than the +resolver in four ways (`slots.SLOT`, `properties.items[].children`, +`properties.body`, `properties.footer`) and narrower in one (it skips +source-authored pages), so adopting it would have recreated the pair's other +half — offering keys the resolver ignores. + +Coverage denominators move with it, deliberately and measured: nested copy +previously counted as neither translated nor missing. In this repo that is +`examples/app-showcase`, whose untranslated-declared-string count went +393 to 403 (six command-center KPI labels, four pricing CTA labels — all real +copy the extractor could not see); the ten are translated in the same change, +so the frozen ratchet baseline is unchanged at 393. From 9ac73cd02d9e3cb416e4647adb213b985d286272 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 11:38:23 +0000 Subject: [PATCH 5/5] chore(cli-test): resolve the parity test's extractor import, and lower the TEST_DEBT ledger to the measurement Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- .../test/platform-page-i18n-parity.test.ts | 2 +- scripts/check-type-check-coverage.mjs | 21 ++++++++++++------- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index c1ac79e4d6..0fd12c3541 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -29,7 +29,7 @@ import { import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; import { SetupAppTranslations } from '@objectstack/platform-objects'; import { PAGE_COMPONENT_COPY_KEYS, translatePage } from '@objectstack/spec/system'; -import { collectExpectedEntries } from '../src/utils/i18n-extract'; +import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; /** The pages exactly as the plugins register them with the kernel. */ const PAGES = [ diff --git a/scripts/check-type-check-coverage.mjs b/scripts/check-type-check-coverage.mjs index 7b47f85065..6672619b0f 100644 --- a/scripts/check-type-check-coverage.mjs +++ b/scripts/check-type-check-coverage.mjs @@ -752,7 +752,7 @@ const EXEMPT = { // the fix: widen a hidden test layer's `include` one file at a time and // measure each addition, because a wholesale glob can bill the layer for a // non-test file it never asked to cover.) -// `@objectstack/cli` (146 raw across 65 files, after #8612 repaired the first +// `@objectstack/cli` (144 raw across 65 files, after #8612 repaired the first // two of its 59 missing import extensions) is deliberately NOT part of that // graduation -- it is a programme rather than a sitting, and its entry stands. // @@ -905,8 +905,14 @@ const TEST_DEBT = { + '`GET /meta/:type/:name` envelope convergence). Nothing else in the package moved.', }, '@objectstack/cli': { - errors: 146, - note: 'TS7006 x60 (implicit any), TS2835 x57 (NodeNext extensions), TS2339 x24, TS2307 x3, TS18046 x2. ' + errors: 144, + note: 'TS7006 x59 (implicit any), TS2835 x56 (NodeNext extensions), TS2339 x24, TS2307 x3, TS18046 x2. ' + + 'LOWERED 146 -> 144 (#13109) and RE-TALLIED above from the same run, not rescaled: ' + + 'test/platform-page-i18n-parity.test.ts 2 -> 0 (1 TS2835 + 1 TS7006), from adding the `.js` ' + + 'extension to its one `../src/utils/i18n-extract` import -- the same one-import repair #8612 made ' + + 'twice below, taken here because that file gained new tests in the same PR and untyped test code ' + + 'is what let the cascade grow. FULLY ATTRIBUTED: no other file moved, and the per-code and ' + + 'per-file tallies below were re-measured whole rather than decremented. ' + 'The package #7353 was really about, and the largest single thing the exclude-shaped detector could ' + 'not see: `tsconfig.json` says `include: ["src"]` and has no `exclude` AT ALL, so there was never an ' + 'exclusion to notice, and the test files in the sibling `test/` tree are read by nothing -- not ' @@ -919,11 +925,12 @@ const TEST_DEBT = { + '(1 TS2835 + 34 TS7006) and test/i18n-extract.test.ts 7 -> 0 (1 TS2835 + 6 TS7006), from adding the ' + '`.js` extension to one import each. Outside those two files the before and after diagnostic sets ' + 'are identical line for line, and nothing new appeared anywhere. ' - + 'WHAT THE PILE IS NOW MADE OF, and it is not a nearly-graduated one: 57 of the 59 extension-less ' - + 'relative imports this layer carried are still there, spread over 24 files, and every one of the 60 ' + + 'WHAT THE PILE IS NOW MADE OF, and it is not a nearly-graduated one: 56 of the 59 extension-less ' + + 'relative imports this layer carried are still there, spread over 23 files, and every one of the 59 ' + 'surviving TS7006 sits in a file that also carries a TS2835 -- there is no implicit-any anywhere in ' - + 'this layer without a broken import above it. Read the top-of-ledger NodeNext note before sizing it: ' - + 'TS2835 plus the cascade it causes are 117 of the 146 and are 57 repairs, not 117. Concentrated ' + + 'this layer without a broken import above it, and the 23 files carrying a TS2835 are EVERY file in ' + + 'this layer that carries any error at all. Read the top-of-ledger NodeNext note before sizing it: ' + + 'TS2835 plus the cascade it causes are 115 of the 144 and are 56 repairs, not 115. Concentrated ' + 'rather than spread -- test/data-model-rules.test.ts x26, test/i18n-declared-surface-gate.test.ts ' + 'x19, test/i18n-section-coverage.test.ts x18, test/commands.test.ts x15, ' + 'test/remote-api-commands.test.ts x12 are 90 of it. '