Skip to content

Commit 5529a37

Browse files
os-litantclaude
andauthored
test(cli): put the three shipped platform record pages under an i18n gate (#15739)
* test(cli): put the three shipped platform record pages under an i18n gate The three record pages plugin-auth and plugin-security contribute (sys_user_detail / sys_organization_detail / sys_position_detail) were in no `os i18n extract` config and under no gate at all, so `check:i18n-coverage`'s 0 for platform-objects read as "checked, clean" over a population that never contained them. Measured with the real `collectExpectedEntries`: the three pages together offer exactly three keys, one page-level `label` each. All three author `regions: []` and everything under `slots.*`, and the shared walk roots at `regions[].components[]`, so 45 further authored copy sites -- every one an inline locale map -- have no bundle face. A region-authored control page does get its component copy offered, so the extractor is working and the absence is the pages' shape. Adds the three missing `pages.*` bundle entries in all four shipped locales, and extends the existing plugin-page drift guard with a block that reads its population from the `@objectstack/platform-objects/pages` barrel: a fourth page joins the gate by existing, and every inline locale map on those pages must carry every shipped locale, so a new section heading authored in English alone now reds instead of shipping green. Widens no walk and moves no baseline. Whether the extractor should see inline maps is a maintainer decision open on #14749. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(i18n): name the owner of the platform record pages from both sides `check:i18n-coverage`'s baselined 0 for platform-objects covers a population that never contained the three plugin-contributed record pages, and neither the extract config nor the gate said so. Both headers now state the boundary and name the gate that owns those pages, on the same precedent the Setup nav half already set. Adds the changeset for the user-visible half: the Setup record header for User / Organization / Position now localizes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * wip: pre-ablation checkpoint * test(cli): scope the record-page gate to what needs no ruling Drops the `pages.*` bundle entries and the two assertions that depended on them. Measured reason, recorded in the block header: adding those entries turns `check:app-nav-i18n` red on the two plugin-auth pages as phantom keys -- its CONTRIBUTORS roster is a NAV roster that omits plugin-auth, and adding it is not mechanical (`new AuthPlugin({})` refuses to boot without a secret, and every entry must land at least one nav id, which plugin-auth's conditional nav_sso_providers cannot promise). Splitting that roster is a gate composition decision, so it is escalated on the card rather than taken here, and no gate is weakened to make room for it. What remains needs no ruling: the population floor, the boundary pin, and the inline-locale-map completeness guard. No changeset -- nothing here publishes from a package (skip-changeset). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e52bb6a commit 5529a37

3 files changed

Lines changed: 218 additions & 0 deletions

File tree

packages/cli/test/platform-page-i18n-parity.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
} from '@objectstack/cloud-connection';
2929
import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp';
3030
import { SetupAppTranslations } from '@objectstack/platform-objects';
31+
import * as PlatformPages from '@objectstack/platform-objects/pages';
3132
import { PAGE_COMPONENT_COPY_KEYS, translatePage } from '@objectstack/spec/system';
3233
import { collectExpectedEntries } from '../src/utils/i18n-extract.js';
3334

@@ -472,3 +473,178 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => {
472473
expect(translated.regions[0].components[0].properties.title).toEqual('L');
473474
});
474475
});
476+
477+
478+
// --- The three shipped platform RECORD pages (#14817) ----------------------
479+
//
480+
// The guard at the top of this file owns the plugin-carried Setup pages whose
481+
// copy lives in the BUNDLE. This block owns the other three pages the platform
482+
// ships -- `sys_user_detail`, `sys_organization_detail`, `sys_position_detail`,
483+
// contributed by plugin-auth and plugin-security -- whose copy is almost
484+
// entirely authored INLINE, and which were in no `os i18n extract` config and
485+
// under no gate at all.
486+
//
487+
// ## The measurement this block was written from
488+
//
489+
// Fed through the real `collectExpectedEntries`, the three pages together
490+
// offer exactly THREE keys -- one page-level `label` each. Nothing else. All
491+
// three author `regions: []` and put every component under `slots.*`, and the
492+
// shared walk (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots
493+
// at `regions[].components[]` only. So 45 further authored copy sites, every
494+
// one of them an inline `{ en, 'zh-CN', ... }` locale map, are not reachable
495+
// from the bundle face at all. A control page authored under `regions` with a
496+
// `component.id` DOES get its component copy offered, which is how we know the
497+
// extractor is working and the absence is the pages' SHAPE.
498+
//
499+
// ## Why that is not a defect this block tries to fix
500+
//
501+
// The inline map is the RULED route for page copy, not a workaround: the
502+
// maintainer ruled (2026-08-06) that it is a delivered capability, which is why
503+
// `I18nLabelSchema` is a union of a plain string and an inline map, and why
504+
// `translation.zod.ts` declines `content` on the bundle face for the identical
505+
// shape. Whether the EXTRACTOR should also see those maps is an open question
506+
// (#14749) and a maintainer decision. This block therefore does not widen
507+
// anything -- it makes the boundary measurable and puts the surface under a
508+
// gate for the first time.
509+
//
510+
// ## What each assertion buys
511+
//
512+
// The harm recorded on #14817 is not today's debt (there is none) -- it is that
513+
// `check:i18n-coverage`'s `0` for `platform-objects` reads as "checked, clean"
514+
// over a population that never contained these pages, so "a fourth plugin page,
515+
// or one new untranslated section heading, lands green". The population below
516+
// is read from the `@objectstack/platform-objects/pages` BARREL rather than
517+
// listed here, so a fourth page joins this gate by existing; and the inline-map
518+
// assertion judges the authoring site, which is the half the bundle face
519+
// cannot see. Both directions of that sentence now red instead of shipping.
520+
//
521+
// ## What this block deliberately does NOT assert, and the measurement why
522+
//
523+
// It does not require a `pages.*` BUNDLE entry for these three. That was tried
524+
// and measured: the three page-level `label`s are the only keys the extractor
525+
// offers, so translating them is the one piece of real debt here (`User` /
526+
// `Organization` / `Position` render in English in every locale). Adding those
527+
// entries turns `check:app-nav-i18n` RED on two of the three --
528+
// `pages.sys_user_detail` and `pages.sys_organization_detail` are reported as
529+
// keys "the booted composition contains no page by that name", its phantom-key
530+
// verdict. That gate's `CONTRIBUTORS` roster is deliberately explicit and
531+
// deliberately a NAV roster: `@objectstack/plugin-auth`, which contributes
532+
// those two pages, is not in it, and adding it is not a one-line edit --
533+
// `new AuthPlugin({})` refuses to boot ("secret is required"), and the roster
534+
// separately requires every entry to land at least one nav id, which
535+
// plugin-auth's conditional `nav_sso_providers` cannot promise. Only
536+
// `sys_position_detail` (plugin-security, which IS in the roster) verifies
537+
// clean. Splitting that roster into a nav population and a page population is a
538+
// change to a gate's composition contract, so it is escalated rather than taken
539+
// here. Until it is ruled, a `pages.*` entry for the plugin-auth pages would be
540+
// exactly the unverifiable key `check:app-nav-i18n` exists to refuse.
541+
542+
/** Every page the platform's own `pages` barrel exports, as the plugins take them. */
543+
const RECORD_PAGES: Array<Record<string, any>> = Object.values(
544+
PlatformPages as unknown as Record<string, unknown>,
545+
).filter(
546+
(v): v is Record<string, any> =>
547+
Boolean(v) && typeof v === 'object' && typeof (v as any).name === 'string',
548+
);
549+
550+
/** The locales the shipped bundle actually carries -- never a hard-coded list. */
551+
const SHIPPED_LOCALES = Object.keys(SetupAppTranslations as Record<string, unknown>);
552+
553+
/**
554+
* A key is a locale code (`en`, `zh-CN`). Deliberately a shape test rather than
555+
* a membership test against `SHIPPED_LOCALES`: a map carrying a locale the
556+
* bundle does not ship is still an inline map, and must still be judged.
557+
*/
558+
const LOCALE_KEY = /^[a-z]{2}(-[A-Z]{2})?$/;
559+
560+
/** An inline `I18nLabel` map: every key a locale code, every value a string. */
561+
const isInlineLocaleMap = (value: unknown): value is Record<string, string> => {
562+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
563+
const keys = Object.keys(value as object);
564+
return keys.length > 0
565+
&& keys.every((k) => LOCALE_KEY.test(k))
566+
&& Object.values(value as Record<string, unknown>).every((v) => typeof v === 'string');
567+
};
568+
569+
/**
570+
* Every inline locale map anywhere in a page document, by authored path. A
571+
* generic JSON walk with its own cycle guard, deliberately NOT a copy of either
572+
* the resolver's or the extractor's traversal -- the whole point is to reach
573+
* what those two do not.
574+
*/
575+
const inlineLocaleMaps = (
576+
node: unknown,
577+
path: string,
578+
out: Array<{ path: string; locales: string[] }> = [],
579+
seen = new Set<object>(),
580+
): Array<{ path: string; locales: string[] }> => {
581+
if (!node || typeof node !== 'object') return out;
582+
if (seen.has(node)) return out;
583+
seen.add(node);
584+
if (Array.isArray(node)) {
585+
node.forEach((item, i) => inlineLocaleMaps(item, `${path}[${i}]`, out, seen));
586+
return out;
587+
}
588+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
589+
const here = path ? `${path}.${key}` : key;
590+
if (isInlineLocaleMap(value)) {
591+
out.push({ path: here, locales: Object.keys(value) });
592+
continue;
593+
}
594+
inlineLocaleMaps(value, here, out, seen);
595+
}
596+
return out;
597+
};
598+
599+
describe('shipped platform record pages -- i18n ownership (#14817)', () => {
600+
it('reads a non-empty population from the pages barrel', () => {
601+
// A floor, not an equality: adding a page is ordinary work and must not
602+
// red here. What this refuses is the scan that finds NOTHING -- an empty
603+
// population would satisfy every `for` loop below and report success over
604+
// zero pages, which is the exact shape of the `0` that reads as "clean".
605+
expect(RECORD_PAGES.length).toBeGreaterThanOrEqual(3);
606+
expect(RECORD_PAGES.map((p) => p.name).sort()).toEqual(
607+
expect.arrayContaining(['sys_organization_detail', 'sys_position_detail', 'sys_user_detail']),
608+
);
609+
expect(SHIPPED_LOCALES).toContain(EN);
610+
expect(SHIPPED_LOCALES.length).toBeGreaterThan(1);
611+
});
612+
613+
it('records that the extractor reaches the page label and nothing under `slots`', () => {
614+
// A BOUNDARY PIN, not an endorsement. It states the measured fact that the
615+
// shared walk roots at `regions[].components[]` and these pages author
616+
// `regions: []`, so the 45 inline sites under `slots.*` have no bundle
617+
// face. If the walk is ever widened -- a maintainer decision open on
618+
// #14749 -- this reds, and the person widening it is told, at the exact
619+
// moment they can act on it, that these three pages gain a bundle surface
620+
// that needs entries and a coverage home. That notice is the whole value:
621+
// today the same change would land green over an unmeasured population.
622+
for (const page of RECORD_PAGES) {
623+
const offered = collectExpectedEntries({ pages: [page] } as any)
624+
.filter((e) => e.path[0] === 'pages' && e.path[1] === page.name)
625+
.map((e) => e.path.slice(2).join('.'))
626+
.sort();
627+
expect({ page: page.name, regions: page.regions, offered })
628+
.toEqual({ page: page.name, regions: [], offered: ['label'] });
629+
}
630+
});
631+
632+
it('holds every inline locale map on those pages complete in every shipped locale', () => {
633+
// The recurrence guard, and the answer to "nobody would learn if it stopped
634+
// being zero". These maps are invisible to `os i18n extract` and therefore
635+
// to `check:i18n-coverage`; before this assertion a new section heading
636+
// authored with `en` alone shipped green and rendered English to every
637+
// reader. The population is measured off the documents, so it grows with
638+
// the pages instead of needing a list here.
639+
const maps = RECORD_PAGES.flatMap((page) => inlineLocaleMaps(page, page.name));
640+
641+
// Same refusal as the population floor: zero maps means the walk broke, not
642+
// that the pages went monolingual.
643+
expect(maps.length).toBeGreaterThanOrEqual(45);
644+
645+
const incomplete = maps
646+
.filter((m) => SHIPPED_LOCALES.some((locale) => !m.locales.includes(locale)))
647+
.map((m) => ({ path: m.path, missing: SHIPPED_LOCALES.filter((l) => !m.locales.includes(l)) }));
648+
expect(incomplete).toEqual([]);
649+
});
650+
});

packages/platform-objects/scripts/i18n-extract.config.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,32 @@
6262
* still gates this package's STATIC declared surface and its 0 is real for
6363
* that; it simply is not the owner of the runtime half.
6464
*
65+
* The SAME `0` carries the same ambiguity for a SECOND runtime-composed
66+
* surface, and this config is why: it declares no `pages` key at all. The
67+
* three shipped record pages -- `sys_user_detail`,
68+
* `sys_organization_detail` (plugin-auth) and `sys_position_detail`
69+
* (plugin-security) -- reach the platform through those plugins, so they
70+
* are in no extract config's population and the ratchet never looked at
71+
* them either. Declaring them here would not by itself fix that, which is
72+
* the part worth writing down: measured through the real
73+
* `collectExpectedEntries`, the three offer exactly THREE keys between
74+
* them (one page-level `label` each). All three author `regions: []` and
75+
* put every component under `slots.*`, and the shared walk
76+
* (`walkAddressedPageComponents`, `@objectstack/spec/system`) roots at
77+
* `regions[].components[]` -- so 45 further authored copy sites, every one
78+
* an inline `{ en, 'zh-CN', ... }` locale map, have no bundle face to be
79+
* counted against. A config-only change would declare pages the walk still
80+
* cannot see: it would look like a fix and measure nothing.
81+
*
82+
* Their gate is `packages/cli/test/platform-page-i18n-parity.test.ts`,
83+
* which owns both halves from the other side -- a `pages.*` bundle entry
84+
* per shipped locale for every page the
85+
* `@objectstack/platform-objects/pages` barrel exports, and a completeness
86+
* check over every inline locale map on those documents, which is the half
87+
* no extractor can reach. Whether the extractor SHOULD reach inline maps is
88+
* a maintainer decision open on #14749; the inline map itself is the ruled
89+
* authoring route (2026-08-06), not a workaround.
90+
*
6591
* Omitting the hand-authored half was a measurable bug, not a style choice:
6692
* this config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and
6793
* SystemOverviewDashboard, so coverage counted all 77 `apps.*`/`dashboards.*`

scripts/check-i18n-coverage.mjs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@
4848
// different questions of different inputs, and folding a kernel boot into an
4949
// `os lint` loop would make neither readable.
5050
//
51+
// The Setup nav is not the only surface in that class, and the second one is
52+
// worth naming here because its `0` is the same `0`. `platform-objects` ships
53+
// three record pages -- `sys_user_detail`, `sys_organization_detail`,
54+
// `sys_position_detail` -- contributed at runtime by plugin-auth and
55+
// plugin-security. Its extract config declares no `pages` key, so they are not
56+
// in this gate's population, and its baselined `0` says nothing about them.
57+
// Declaring them would not be enough either: all three author `regions: []`
58+
// with every component under `slots.*`, and the walk behind `os lint` roots at
59+
// `regions[].components[]`, so the three page-level `label`s are the only keys
60+
// that exist for them -- 45 further authored copy sites are inline locale maps
61+
// with no bundle face at all. Their owner is
62+
// `packages/cli/test/platform-page-i18n-parity.test.ts`, which judges the
63+
// bundle entries AND the inline maps directly off the page documents. Same
64+
// rule as the nav half: do not extend this script to cover it, and do not read
65+
// its `0` as a verdict on those pages.
66+
//
5167
// That requirement is now CHECKED, not merely declared (#5862). It used to be the
5268
// sentence above and nothing else, and in an installed-but-unbuilt worktree the
5369
// gate answered with an uncaught exception plus a node stack:

0 commit comments

Comments
 (0)