Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions packages/cli/test/platform-page-i18n-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Record<string, any>> = Object.values(
PlatformPages as unknown as Record<string, unknown>,
).filter(
(v): v is Record<string, any> =>
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<string, unknown>);

/**
* 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<string, string> => {
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<string, unknown>).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<object>(),
): 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<string, unknown>)) {
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([]);
});
});
26 changes: 26 additions & 0 deletions packages/platform-objects/scripts/i18n-extract.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.*`
Expand Down
16 changes: 16 additions & 0 deletions scripts/check-i18n-coverage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading