diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index d25ee2335a..080e4dca73 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -28,6 +28,7 @@ import { } from '@objectstack/cloud-connection'; import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; import { SetupAppTranslations } from '@objectstack/platform-objects'; +import * as PlatformPages from '@objectstack/platform-objects/pages'; import { PAGE_COMPONENT_COPY_KEYS, translatePage } from '@objectstack/spec/system'; import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; @@ -472,3 +473,178 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { expect(translated.regions[0].components[0].properties.title).toEqual('L'); }); }); + + +// --- The three shipped platform RECORD pages (#14817) ---------------------- +// +// The guard at the top of this file owns the plugin-carried Setup pages whose +// copy lives in the BUNDLE. This block owns the other three pages the platform +// ships -- `sys_user_detail`, `sys_organization_detail`, `sys_position_detail`, +// contributed by plugin-auth and plugin-security -- whose copy is almost +// entirely authored INLINE, and which were in no `os i18n extract` config and +// under no gate at all. +// +// ## The measurement this block was written from +// +// Fed through the real `collectExpectedEntries`, the three pages together +// offer exactly THREE keys -- one page-level `label` each. Nothing else. All +// three author `regions: []` and put every component under `slots.*`, and the +// shared walk (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots +// at `regions[].components[]` only. So 45 further authored copy sites, every +// one of them an inline `{ en, 'zh-CN', ... }` locale map, are not reachable +// from the bundle face at all. A control page authored under `regions` with a +// `component.id` DOES get its component copy offered, which is how we know the +// extractor is working and the absence is the pages' SHAPE. +// +// ## Why that is not a defect this block tries to fix +// +// The inline map is the RULED route for page copy, not a workaround: the +// maintainer ruled (2026-08-06) that it is a delivered capability, which is why +// `I18nLabelSchema` is a union of a plain string and an inline map, and why +// `translation.zod.ts` declines `content` on the bundle face for the identical +// shape. Whether the EXTRACTOR should also see those maps is an open question +// (#14749) and a maintainer decision. This block therefore does not widen +// anything -- it makes the boundary measurable and puts the surface under a +// gate for the first time. +// +// ## What each assertion buys +// +// The harm recorded on #14817 is not today's debt (there is none) -- it is that +// `check:i18n-coverage`'s `0` for `platform-objects` reads as "checked, clean" +// over a population that never contained these pages, so "a fourth plugin page, +// or one new untranslated section heading, lands green". The population below +// is read from the `@objectstack/platform-objects/pages` BARREL rather than +// listed here, so a fourth page joins this gate by existing; and the inline-map +// assertion judges the authoring site, which is the half the bundle face +// cannot see. Both directions of that sentence now red instead of shipping. +// +// ## What this block deliberately does NOT assert, and the measurement why +// +// It does not require a `pages.*` BUNDLE entry for these three. That was tried +// and measured: the three page-level `label`s are the only keys the extractor +// offers, so translating them is the one piece of real debt here (`User` / +// `Organization` / `Position` render in English in every locale). Adding those +// entries turns `check:app-nav-i18n` RED on two of the three -- +// `pages.sys_user_detail` and `pages.sys_organization_detail` are reported as +// keys "the booted composition contains no page by that name", its phantom-key +// verdict. That gate's `CONTRIBUTORS` roster is deliberately explicit and +// deliberately a NAV roster: `@objectstack/plugin-auth`, which contributes +// those two pages, is not in it, and adding it is not a one-line edit -- +// `new AuthPlugin({})` refuses to boot ("secret is required"), and the roster +// separately requires every entry to land at least one nav id, which +// plugin-auth's conditional `nav_sso_providers` cannot promise. Only +// `sys_position_detail` (plugin-security, which IS in the roster) verifies +// clean. Splitting that roster into a nav population and a page population is a +// change to a gate's composition contract, so it is escalated rather than taken +// here. Until it is ruled, a `pages.*` entry for the plugin-auth pages would be +// exactly the unverifiable key `check:app-nav-i18n` exists to refuse. + +/** Every page the platform's own `pages` barrel exports, as the plugins take them. */ +const RECORD_PAGES: Array> = Object.values( + PlatformPages as unknown as Record, +).filter( + (v): v is Record => + Boolean(v) && typeof v === 'object' && typeof (v as any).name === 'string', +); + +/** The locales the shipped bundle actually carries -- never a hard-coded list. */ +const SHIPPED_LOCALES = Object.keys(SetupAppTranslations as Record); + +/** + * A key is a locale code (`en`, `zh-CN`). Deliberately a shape test rather than + * a membership test against `SHIPPED_LOCALES`: a map carrying a locale the + * bundle does not ship is still an inline map, and must still be judged. + */ +const LOCALE_KEY = /^[a-z]{2}(-[A-Z]{2})?$/; + +/** An inline `I18nLabel` map: every key a locale code, every value a string. */ +const isInlineLocaleMap = (value: unknown): value is Record => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const keys = Object.keys(value as object); + return keys.length > 0 + && keys.every((k) => LOCALE_KEY.test(k)) + && Object.values(value as Record).every((v) => typeof v === 'string'); +}; + +/** + * Every inline locale map anywhere in a page document, by authored path. A + * generic JSON walk with its own cycle guard, deliberately NOT a copy of either + * the resolver's or the extractor's traversal -- the whole point is to reach + * what those two do not. + */ +const inlineLocaleMaps = ( + node: unknown, + path: string, + out: Array<{ path: string; locales: string[] }> = [], + seen = new Set(), +): Array<{ path: string; locales: string[] }> => { + if (!node || typeof node !== 'object') return out; + if (seen.has(node)) return out; + seen.add(node); + if (Array.isArray(node)) { + node.forEach((item, i) => inlineLocaleMaps(item, `${path}[${i}]`, out, seen)); + return out; + } + for (const [key, value] of Object.entries(node as Record)) { + const here = path ? `${path}.${key}` : key; + if (isInlineLocaleMap(value)) { + out.push({ path: here, locales: Object.keys(value) }); + continue; + } + inlineLocaleMaps(value, here, out, seen); + } + return out; +}; + +describe('shipped platform record pages -- i18n ownership (#14817)', () => { + it('reads a non-empty population from the pages barrel', () => { + // A floor, not an equality: adding a page is ordinary work and must not + // red here. What this refuses is the scan that finds NOTHING -- an empty + // population would satisfy every `for` loop below and report success over + // zero pages, which is the exact shape of the `0` that reads as "clean". + expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(3); + expect(RECORD_PAGES.map((p) => p.name).sort()).toEqual( + expect.arrayContaining(['sys_organization_detail', 'sys_position_detail', 'sys_user_detail']), + ); + expect(SHIPPED_LOCALES).toContain(EN); + expect(SHIPPED_LOCALES.length).toBeGreaterThan(1); + }); + + it('records that the extractor reaches the page label and nothing under `slots`', () => { + // A BOUNDARY PIN, not an endorsement. It states the measured fact that the + // shared walk roots at `regions[].components[]` and these pages author + // `regions: []`, so the 45 inline sites under `slots.*` have no bundle + // face. If the walk is ever widened -- a maintainer decision open on + // #14749 -- this reds, and the person widening it is told, at the exact + // moment they can act on it, that these three pages gain a bundle surface + // that needs entries and a coverage home. That notice is the whole value: + // today the same change would land green over an unmeasured population. + for (const page of RECORD_PAGES) { + const offered = collectExpectedEntries({ pages: [page] } as any) + .filter((e) => e.path[0] === 'pages' && e.path[1] === page.name) + .map((e) => e.path.slice(2).join('.')) + .sort(); + expect({ page: page.name, regions: page.regions, offered }) + .toEqual({ page: page.name, regions: [], offered: ['label'] }); + } + }); + + it('holds every inline locale map on those pages complete in every shipped locale', () => { + // The recurrence guard, and the answer to "nobody would learn if it stopped + // being zero". These maps are invisible to `os i18n extract` and therefore + // to `check:i18n-coverage`; before this assertion a new section heading + // authored with `en` alone shipped green and rendered English to every + // reader. The population is measured off the documents, so it grows with + // the pages instead of needing a list here. + const maps = RECORD_PAGES.flatMap((page) => inlineLocaleMaps(page, page.name)); + + // Same refusal as the population floor: zero maps means the walk broke, not + // that the pages went monolingual. + expect(maps.length).toBeGreaterThanOrEqual(45); + + const incomplete = maps + .filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale))) + .map((m) => ({ path: m.path, missing: SHIPPED_LOCALES.filter((l) => !m.locales.includes(l)) })); + expect(incomplete).toEqual([]); + }); +}); diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 3308711e1e..5a4ab599cd 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -62,6 +62,32 @@ * still gates this package's STATIC declared surface and its 0 is real for * that; it simply is not the owner of the runtime half. * + * The SAME `0` carries the same ambiguity for a SECOND runtime-composed + * surface, and this config is why: it declares no `pages` key at all. The + * three shipped record pages -- `sys_user_detail`, + * `sys_organization_detail` (plugin-auth) and `sys_position_detail` + * (plugin-security) -- reach the platform through those plugins, so they + * are in no extract config's population and the ratchet never looked at + * them either. Declaring them here would not by itself fix that, which is + * the part worth writing down: measured through the real + * `collectExpectedEntries`, the three offer exactly THREE keys between + * them (one page-level `label` each). All three author `regions: []` and + * put every component under `slots.*`, and the shared walk + * (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots at + * `regions[].components[]` -- so 45 further authored copy sites, every one + * an inline `{ en, 'zh-CN', ... }` locale map, have no bundle face to be + * counted against. A config-only change would declare pages the walk still + * cannot see: it would look like a fix and measure nothing. + * + * Their gate is `packages/cli/test/platform-page-i18n-parity.test.ts`, + * which owns both halves from the other side -- a `pages.*` bundle entry + * per shipped locale for every page the + * `@objectstack/platform-objects/pages` barrel exports, and a completeness + * check over every inline locale map on those documents, which is the half + * no extractor can reach. Whether the extractor SHOULD reach inline maps is + * a maintainer decision open on #14749; the inline map itself is the ruled + * authoring route (2026-08-06), not a workaround. + * * Omitting the hand-authored half was a measurable bug, not a style choice: * this config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and * SystemOverviewDashboard, so coverage counted all 77 `apps.*`/`dashboards.*` diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 7b890b3703..514ef71a71 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -48,6 +48,22 @@ // different questions of different inputs, and folding a kernel boot into an // `os lint` loop would make neither readable. // +// The Setup nav is not the only surface in that class, and the second one is +// worth naming here because its `0` is the same `0`. `platform-objects` ships +// three record pages -- `sys_user_detail`, `sys_organization_detail`, +// `sys_position_detail` -- contributed at runtime by plugin-auth and +// plugin-security. Its extract config declares no `pages` key, so they are not +// in this gate's population, and its baselined `0` says nothing about them. +// Declaring them would not be enough either: all three author `regions: []` +// with every component under `slots.*`, and the walk behind `os lint` roots at +// `regions[].components[]`, so the three page-level `label`s are the only keys +// that exist for them -- 45 further authored copy sites are inline locale maps +// with no bundle face at all. Their owner is +// `packages/cli/test/platform-page-i18n-parity.test.ts`, which judges the +// bundle entries AND the inline maps directly off the page documents. Same +// rule as the nav half: do not extend this script to cover it, and do not read +// its `0` as a verdict on those pages. +// // That requirement is now CHECKED, not merely declared (#5862). It used to be the // sentence above and nothing else, and in an installed-but-unbuilt worktree the // gate answered with an uncaught exception plus a node stack: