diff --git a/.changeset/platform-record-page-labels-translated.md b/.changeset/platform-record-page-labels-translated.md new file mode 100644 index 0000000000..a5d83c57d1 --- /dev/null +++ b/.changeset/platform-record-page-labels-translated.md @@ -0,0 +1,11 @@ +--- +"@objectstack/platform-objects": minor +--- + +The three platform record pages carry a translated label in every shipped locale. + +`sys_user_detail`, `sys_organization_detail` and `sys_position_detail` each declare a page-level `label` — `User`, `Organization`, `Position` — and those three strings rendered in English in every locale, including `zh-CN`, `ja-JP` and `es-ES`. They are the only keys on those pages the extractor reaches: all three author `regions: []`, so the shared walk (which roots at `regions[].components[]`) finds nothing else, and their other 45 authored copy sites are inline locale maps under `slots.*` that already carry all four locales. + +`SetupAppTranslations` now declares a `pages.*` entry for each of the three in all four locale files, so `translatePage` overlays the page label the same way it already does for the plugin-carried Setup pages. Their recorded source hashes are added alongside (`.source-hashes.ts`), so a later edit to one of the English literals marks the translations stale instead of serving a translation of a string that no longer exists. + +Nothing about the pages' shape changed: `label` is still the only key the extractor offers them, and the inline maps under `slots.*` are untouched. diff --git a/packages/cli/scripts/check-app-nav-i18n.mjs b/packages/cli/scripts/check-app-nav-i18n.mjs index eb2fc3e31e..4fae0f9c39 100644 --- a/packages/cli/scripts/check-app-nav-i18n.mjs +++ b/packages/cli/scripts/check-app-nav-i18n.mjs @@ -37,41 +37,33 @@ // From the repo root that is `pnpm check:app-nav-i18n` (which runs both). // // --------------------------------------------------------------------------- -// The SECOND assertion: `pages.*` default-locale content parity (#8764) +// What this gate NO LONGER judges: `pages.*` (#8764 -> #15743) // --------------------------------------------------------------------------- -// The platform bundle `packages/platform-objects/src/apps/translations/en.ts` -// has three top-level sections. `apps.*` and `dashboards.*` are compared -// against their sources verbatim by `app-nav-translation-parity.test.ts` -// (#8721) — a static walk, sound because those sources live in that same -// package. The third section, `pages.*`, was compared by NOTHING, in any -// locale including `en`, because its three sources are authored in OTHER -// packages: +// It used to carry a second, unrelated assertion: that the default locale's +// `pages.*` section still said what the composed page metadata says. That +// verdict now lives in `packages/cli/test/platform-page-i18n-parity.test.ts`, +// which reads its population from the `@objectstack/platform-objects/pages` +// barrel and the contributing plugins' UI bundles and needs no boot. // -// pages.marketplace_installed → @objectstack/cloud-connection -// src/marketplace-ui.ts -// pages.cloud_connection_settings → @objectstack/cloud-connection -// src/cloud-connection-ui.ts -// pages.connect_agent → @objectstack/mcp src/connect-ui.ts +// The move is #15743's ruling (option B, ruled at PR #15739), and the reason +// is this file's own defect class rather than tidiness: `CONTRIBUTORS` below +// is a NAV roster — every entry must land a nav id — and it was silently +// serving a SECOND population, the pages. Those two populations are not the +// same set. Measured on the composition this gate boots: it contains four +// pages (`cloud_connection_settings`, `connect_agent`, `marketplace_installed`, +// `sys_position_detail`), while the platform ships six — `sys_user_detail` and +// `sys_organization_detail` come from `@objectstack/plugin-auth`, which cannot +// join a NAV roster: `new AuthPlugin({})` refuses to boot without a secret, and +// its only nav contribution (`nav_sso_providers`) is conditional, so it can +// never satisfy the at-least-one-nav-id invariant three lines of this file +// depend on. A bundle entry for either page therefore drew this gate's ORPHAN +// verdict — "the booted composition contains no page by that name" — which was +// a true statement about THIS COMPOSITION and a false one about the platform. // -// `platform-objects` does not depend on those packages and must not (they -// depend on IT), so its own test cannot reach the literals — it says so and -// defers here. This gate can: the two bundles are already in `CONTRIBUTORS` -// below for their NAV contributions, and the same manifests carry `pages:`, -// which `engine.registerApp` registers through `METADATA_ARRAY_KEYS`. So the -// pages are in the composition this gate already boots — measured, not -// assumed — and cost one extra read of the registry. -// -// Why `en` only, and why CONTENT rather than coverage: `en` is not a -// translation, it is a COPY of the source literals (AGENTS.md's i18n row — the -// extractor rewrites it from the source on every run). Comparing it to its -// source is therefore meaningful, and comparing `zh-CN` to the source would be -// backwards — those SHOULD differ. Per-locale key coverage is the first -// assertion's job, one section over. -// -// The defect shape this closes is the one #8721 was filed for, one section -// over: a widget was converted, its bundles kept the pre-conversion string, and -// a fully green build served stale copy in all four locales. A page header -// edited in `cloud-connection` or `mcp` would do exactly that here. +// ⛔ Do not bring it back by widening `CONTRIBUTORS`. That is option A, refused +// on #15743: it makes one roster serve two populations explicitly, with a +// credential fixture and a hand-carved exemption from the nav-id invariant — +// the original defect with better documentation. // // --------------------------------------------------------------------------- // It lives in `packages/cli` on purpose @@ -158,147 +150,6 @@ export function contributorsWithNoNavIds(contributions) { return contributions.filter((c) => c.ids.length === 0).map((c) => c.source); } -// --------------------------------------------------------------------------- -// `pages.*` default-locale parity (#8764) — pure helpers, same self-test -// discipline as the nav ones above. -// --------------------------------------------------------------------------- - -/** - * The default locale. The bundle is a plain locale-keyed record and declares no - * default of its own, so this is the repo-wide convention (AGENTS.md's i18n - * row): `en` is a COPY of the source literals, rewritten from the source by the - * extractor on every run — never a translation. Its absence is a failure rather - * than a skip, below: a gate that silently measures nothing is worse than no - * gate ("Absence must be loud"). - */ -const DEFAULT_LOCALE = 'en'; - -/** The component type whose `title` / `subtitle` the page bundle addresses. */ -const PAGE_HEADER_COMPONENT = 'page:header'; - -/** - * The source literals a composed page offers, keyed the way the translation - * bundle addresses them. The mapping is not invented here — it is the one - * `translatePage` implements and `TranslationDataSchema.pages` documents: - * - * pages..label → the page document's own `label` - * pages..description → the page document's own `description` - * pages..title → every `page:header`'s `properties.title` - * pages..subtitle → every `page:header`'s `properties.subtitle` - * - * `title`/`subtitle` are ARRAYS because the resolver overlays every `page:header` - * in the regions, not the first one — so parity has to hold for all of them or - * the bundle is right about one header and wrong about another. - */ -export function pageSourceCopy(page) { - const headers = []; - for (const region of page?.regions ?? []) { - for (const component of region?.components ?? []) { - if (component?.type === PAGE_HEADER_COMPONENT) headers.push(component?.properties ?? {}); - } - } - const strings = (key) => headers.map((h) => h?.[key]).filter((v) => typeof v === 'string'); - return { - label: typeof page?.label === 'string' ? page.label : undefined, - description: typeof page?.description === 'string' ? page.description : undefined, - title: strings('title'), - subtitle: strings('subtitle'), - }; -} - -/** - * Every way the default-locale `pages.*` section can disagree with the page - * metadata it copies. Three finding kinds, deliberately distinct because their - * REMEDIES differ: - * - * - `drift` the bundle serves a string the source no longer says. Fix the - * bundle (and the other locales, which are now stale too). - * - `orphan` the bundle names a page this composition does not contain. - * This is the ANTI-VACUITY half: without it, renaming a page - * silently reduces this assertion to comparing nothing, and a - * parity gate that cannot fail is the exact defect class #8764 - * is about. - * - `no-source` the bundle declares a key the page has nothing to compare - * against — a phantom key that translates nothing (ADR-0078's - * shape). Delete the key or restore the source field. - * - * ⚠️ `title` implements the resolver's fallback: `pages..title` defaults - * to `pages..label` when omitted. That is not a detail — it is a real - * drift path. Today all three pages declare `label` only, so that ONE key is - * what serves the header title; a header `title` edited to differ from the page - * `label` would be silently overwritten by the label in every locale, `en` - * included, with nothing else in the repo comparing the two. - */ -export function defaultLocalePageDrift(pageTranslations, sourceByName) { - const findings = []; - const add = (kind, path, source, served) => findings.push({ kind, path, source, served }); - - for (const [name, entry] of Object.entries(pageTranslations ?? {})) { - const source = sourceByName?.get(name); - if (!source) { - add('orphan', `pages.${name}`, undefined, undefined); - continue; - } - - // The page document's own fields. - for (const attr of ['label', 'description']) { - const served = entry?.[attr]; - if (typeof served !== 'string') continue; // an undeclared key makes no claim - if (typeof source[attr] !== 'string') { add('no-source', `pages.${name}.${attr}`, undefined, served); continue; } - if (served !== source[attr]) add('drift', `pages.${name}.${attr}`, source[attr], served); - } - - // The `page:header` copy. - const explicitTitle = typeof entry?.title === 'string' ? entry.title : undefined; - const servedTitle = explicitTitle ?? (typeof entry?.label === 'string' ? entry.label : undefined); - const titlePath = `pages.${name}.${explicitTitle !== undefined ? 'title' : 'label'}`; - if (servedTitle !== undefined) { - // An explicit `title` with no header to land on is a phantom key. The - // `label` FALLBACK is not — `label` has already been judged above against - // the page's own field, and a page with no header title is not a page the - // fallback makes a claim about. - if (source.title.length === 0) { - if (explicitTitle !== undefined) add('no-source', titlePath, undefined, explicitTitle); - } else { - for (const t of source.title) if (servedTitle !== t) add('drift', titlePath, t, servedTitle); - } - } - const servedSubtitle = entry?.subtitle; - if (typeof servedSubtitle === 'string') { - if (source.subtitle.length === 0) add('no-source', `pages.${name}.subtitle`, undefined, servedSubtitle); - else for (const s of source.subtitle) if (servedSubtitle !== s) add('drift', `pages.${name}.subtitle`, s, servedSubtitle); - } - } - return findings; -} - -/** Render one `pages.*` finding with the package that authors the source. */ -function renderPageFinding(finding, authoredBy) { - const pageName = finding.path.split('.')[1]; - const from = authoredBy.get(pageName); - const where = from ? ` (authored by ${from})` : ''; - if (finding.kind === 'orphan') { - return ( - `${finding.path} — the ${DEFAULT_LOCALE} bundle declares this page, but the booted composition ` + - `contains no page by that name. Nothing compared it, so every key under it is unverified: ` + - `either the page was renamed (rename the bundle entry with it, in all four locales) or it was ` + - `removed (delete the entry). Left as is, this section's parity assertion silently checks less.` - ); - } - if (finding.kind === 'no-source') { - return ( - `${finding.path}${where} — the ${DEFAULT_LOCALE} bundle declares this key, but the page metadata ` + - `has no corresponding field for it to overlay. It translates nothing: delete the key, or restore ` + - `the field at the source.` - ); - } - return ( - `${finding.path}${where} — the ${DEFAULT_LOCALE} bundle has drifted from the source literal.\n` + - ` source: ${JSON.stringify(finding.source)}\n` + - ` ${DEFAULT_LOCALE} bundle: ${JSON.stringify(finding.served)}` - ); -} - /** Render one locale's shortfall with the source that declared each id. */ function renderMissing(locale, missing, declaredBy) { const lines = missing.map((id) => { @@ -499,92 +350,6 @@ function selfTest() { expect('#5750 verdict names the fallback literal', rendered.includes('HTTP Deliveries'), rendered); expect('#5750 verdict names the locale', rendered.includes('zh-CN'), rendered); - // ------------------------------------------------------------------------- - // `pages.*` default-locale parity (#8764). Recorded from the real - // `MarketplaceInstalledPage`, so the sample is the shape the gate meets. - // ------------------------------------------------------------------------- - const PAGE_SAMPLE = { - name: 'marketplace_installed', - label: 'Installed Apps', - regions: [ - { - name: 'header', - components: [ - { - type: 'page:header', - properties: { - title: 'Installed Apps', - subtitle: "Marketplace packages currently installed into this runtime's kernel.", - }, - }, - ], - }, - { name: 'main', components: [{ type: 'marketplace:installed-list', properties: {} }] }, - ], - }; - const SOURCES = new Map([[PAGE_SAMPLE.name, pageSourceCopy(PAGE_SAMPLE)]]); - const IN_PARITY = { - marketplace_installed: { - label: 'Installed Apps', - subtitle: "Marketplace packages currently installed into this runtime's kernel.", - }, - }; - - const copy = pageSourceCopy(PAGE_SAMPLE); - expect('#8764 reads the page label', copy.label === 'Installed Apps', JSON.stringify(copy)); - expect('#8764 reads header copy out of the regions', copy.title[0] === 'Installed Apps' && copy.subtitle.length === 1, JSON.stringify(copy)); - expect('#8764 a page with no regions yields no header copy, never a crash', pageSourceCopy({ name: 'x' }).title.length === 0, 'a bare page must not throw'); - - expect('#8764 a bundle in parity reports nothing', defaultLocalePageDrift(IN_PARITY, SOURCES).length === 0, JSON.stringify(defaultLocalePageDrift(IN_PARITY, SOURCES))); - - // The defect the card is about: a source string edited in another package, - // the hand-copied bundle left behind. - const subtitleDrift = defaultLocalePageDrift( - { marketplace_installed: { ...IN_PARITY.marketplace_installed, subtitle: 'Packages installed into this kernel.' } }, - SOURCES, - ); - expect('#8764 a drifted subtitle is reported', subtitleDrift.length === 1 && subtitleDrift[0].kind === 'drift', JSON.stringify(subtitleDrift)); - expect('#8764 the drift verdict carries BOTH strings', subtitleDrift[0]?.source?.startsWith('Marketplace packages') && subtitleDrift[0]?.served?.startsWith('Packages installed'), JSON.stringify(subtitleDrift)); - - const labelDrift = defaultLocalePageDrift( - { marketplace_installed: { ...IN_PARITY.marketplace_installed, label: 'Installed Packages' } }, - SOURCES, - ); - // One edited `label` disagrees with the page label AND, through the - // resolver's title fallback, with the header title — two real served strings. - expect('#8764 a drifted label is reported', labelDrift.some((f) => f.kind === 'drift' && f.path === 'pages.marketplace_installed.label'), JSON.stringify(labelDrift)); - - // The title fallback: the bundle declares `label` only, so `label` is what - // serves the header title. A header title edited to differ from the page - // label is drift NOTHING else in the repo compares. - const headerOnlyEdit = new Map([[ - 'marketplace_installed', - pageSourceCopy({ ...PAGE_SAMPLE, regions: [{ name: 'header', components: [{ type: 'page:header', properties: { title: 'Installed Packages', subtitle: IN_PARITY.marketplace_installed.subtitle } }] }] }), - ]]); - const fallbackDrift = defaultLocalePageDrift(IN_PARITY, headerOnlyEdit); - expect('#8764 the title fallback catches a header edited away from the label', fallbackDrift.some((f) => f.kind === 'drift' && f.source === 'Installed Packages'), JSON.stringify(fallbackDrift)); - - // The anti-vacuity half. A renamed page must not silently reduce this - // assertion to comparing nothing. - const orphan = defaultLocalePageDrift(IN_PARITY, new Map()); - expect('#8764 a bundle entry with no page in the composition is reported', orphan.length === 1 && orphan[0].kind === 'orphan', JSON.stringify(orphan)); - expect('#8764 an empty composition does not read as parity', defaultLocalePageDrift(IN_PARITY, new Map()).length > 0, 'an empty source map must never be green'); - - // A key with nothing to overlay translates nothing. - const phantom = defaultLocalePageDrift( - { marketplace_installed: { ...IN_PARITY.marketplace_installed, description: 'Anything' } }, - SOURCES, - ); - expect('#8764 a key the page cannot carry is reported', phantom.some((f) => f.kind === 'no-source' && f.path.endsWith('.description')), JSON.stringify(phantom)); - // …but the label FALLBACK is not a phantom claim about a header that has no - // title, or every headerless page would report a finding it cannot act on. - const headerless = defaultLocalePageDrift({ p: { label: 'P' } }, new Map([['p', pageSourceCopy({ name: 'p', label: 'P' })]])); - expect('#8764 a page with no header title raises nothing from the fallback', headerless.length === 0, JSON.stringify(headerless)); - - const renderedPage = renderPageFinding(subtitleDrift[0], new Map([['marketplace_installed', '@objectstack/cloud-connection']])); - expect('#8764 verdict names the key path', renderedPage.includes('pages.marketplace_installed.subtitle'), renderedPage); - expect('#8764 verdict names the authoring package', renderedPage.includes('@objectstack/cloud-connection'), renderedPage); - // ── The refusal CLASS, and the advisory that must move with it (#14857) ── // // `checkBuildPrerequisite` is this gate's only refusal, and until its printer @@ -665,8 +430,8 @@ function selfTest() { process.exit(1); } console.log( - '✓ check:app-nav-i18n --self-test — the nav walk, the per-locale label verdict, the silent-contributor guard, ' + - 'and the `pages.*` default-locale parity verdict (drift, orphan, phantom key) all go red on the shapes they exist to catch; ' + + '✓ check:app-nav-i18n --self-test — the nav walk, the per-locale label verdict and the silent-contributor guard ' + + 'all go red on the shapes they exist to catch; ' + `and the build-prerequisite refusal exits ${EXIT_PREREQUISITE_NOT_MET} — distinct from a finding's ${EXIT_FINDINGS} — ` + 'with an advisory that names the number it claims (#14857).', ); @@ -786,8 +551,6 @@ const ctx = { /** `{ source, ids }` per contributor, plus id → { source, label } for the verdict. */ const contributions = []; const declaredBy = new Map(); -/** page name → the contributor whose manifest carried it (#8764 verdicts). */ -const pageAuthoredBy = new Map(); for (const contributor of CONTRIBUTORS) { currentSink = []; @@ -822,12 +585,6 @@ for (const contributor of CONTRIBUTORS) { declaredBy.set(item.id, { source: contributor.source, label: item.label }); } } - // Pages this manifest carries (#8764). `engine.registerApp` registers them - // through `METADATA_ARRAY_KEYS`, so the registry is the read-back below; - // this map only records WHO authored each one, for the verdict. - for (const page of manifest?.pages ?? []) { - if (page?.name) pageAuthoredBy.set(page.name, contributor.source); - } // The app shell itself (group anchors) counts as this contributor's ids too. for (const app of manifest?.apps ?? []) { if (app?.name !== APP_NAME) continue; @@ -849,7 +606,6 @@ const mergedIds = collectNavIds(mergedApp?.navigation); // diagnosis pointing somewhere innocent) rebuilt in this gate. const compositionErrors = []; const coverageErrors = []; -const pageParityErrors = []; // 1. The composition is complete — checked BEFORE the coverage verdict, because // an incomplete composition cannot give one. @@ -874,37 +630,14 @@ if (compositionErrors.length === 0) { } } -// 3. The default locale's `pages.*` section still says what its sources say -// (#8764). Same composition, one more read — the pages the contributing -// manifests carry are in the registry, so no second boot is needed. -if (compositionErrors.length === 0) { - const defaultLocaleData = SetupAppTranslations[DEFAULT_LOCALE]; - if (!defaultLocaleData) { - // Loud, never a skip: with no default locale there is nothing to compare, - // and a silent pass here would read as parity in every future audit. - pageParityErrors.push( - `the bundle declares no \`${DEFAULT_LOCALE}\` locale, so the default-locale parity assertion had ` + - `nothing to compare. Locales present: ${Object.keys(SetupAppTranslations).join(', ') || '(none)'}.`, - ); - } else { - const sourceByName = new Map(); - for (const page of engine.registry.listItems('page') ?? []) { - if (page?.name) sourceByName.set(page.name, pageSourceCopy(page)); - } - for (const finding of defaultLocalePageDrift(defaultLocaleData.pages, sourceByName)) { - pageParityErrors.push(renderPageFinding(finding, pageAuthoredBy)); - } - } -} - -const errors = [...compositionErrors, ...coverageErrors, ...pageParityErrors]; +const errors = [...compositionErrors, ...coverageErrors]; if (errors.length) { console.error(`\ncheck-app-nav-i18n: ${errors.length} problem(s)\n`); for (const e of errors) console.error(' • ' + e + '\n'); - // One footer PER bucket that actually fired. The three remedies are + // One footer PER bucket that actually fired. The two remedies are // different, and a footer prescribing one for another is a confident // diagnosis pointing somewhere innocent — the #5862 defect this gate was - // careful not to rebuild when it had two buckets, and no less true with three. + // careful not to rebuild. if (compositionErrors.length) { console.error( ` Nothing was compared: the coverage verdict is only meaningful over a COMPLETE\n` + @@ -920,24 +653,10 @@ if (errors.length) { ` packages/platform-objects/src/apps/translations/ (en, zh-CN, ja-JP, es-ES).`, ); } - if (pageParityErrors.length) { - console.error( - ` The \`pages.*\` findings above are about the DEFAULT locale only (#8764): \`${DEFAULT_LOCALE}\` is a\n` + - ` copy of the source literals, not a translation, so it must say exactly what the page\n` + - ` metadata says. The sources are authored in OTHER packages — @objectstack/cloud-connection\n` + - ` and @objectstack/mcp — which is why no static walk in platform-objects can see them.\n` + - ` Fix by re-copying the source literal into \`pages.*\` in\n` + - ` packages/platform-objects/src/apps/translations/${DEFAULT_LOCALE}.ts — and check whether the\n` + - ` three TRANSLATED locales (zh-CN, ja-JP, es-ES) are now stale against the same edit,\n` + - ` because nothing here can judge that for you.`, - ); - } process.exit(1); } -const judgedPages = Object.keys(SetupAppTranslations[DEFAULT_LOCALE]?.pages ?? {}).length; console.log( `check-app-nav-i18n: OK (${CONTRIBUTORS.length} contributor(s), ${mergedIds.length} merged \`${APP_NAME}\` nav id(s), ` + - `${Object.keys(SetupAppTranslations).length} locale(s), every id labelled in every locale; ` + - `${judgedPages} \`pages.*\` entr(ies) in verbatim parity with their source metadata in \`${DEFAULT_LOCALE}\`).`, + `${Object.keys(SetupAppTranslations).length} locale(s), every id labelled in every locale).`, ); diff --git a/packages/cli/test/platform-page-i18n-parity.test.ts b/packages/cli/test/platform-page-i18n-parity.test.ts index 080e4dca73..6c7eb87b7f 100644 --- a/packages/cli/test/platform-page-i18n-parity.test.ts +++ b/packages/cli/test/platform-page-i18n-parity.test.ts @@ -518,26 +518,32 @@ describe('i18n-extract ↔ translatePage walk parity (#13109)', () => { // 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 +// ## The `pages.*` bundle entry this block once refused -- and the ruling that +// ## delivered it (#15743) // -// 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 +// This section used to say the block "does not require a `pages.*` BUNDLE entry +// for these three", and record why: 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` rendered in English in every +// locale), but adding those entries turned `check:app-nav-i18n` RED on two of +// the three -- `pages.sys_user_detail` and `pages.sys_organization_detail` came +// back as keys "the booted composition contains no page by that name", its +// orphan 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. +// `sys_position_detail` (plugin-security, which IS in the roster) verified +// clean. The block escalated rather than deciding. +// +// It was ruled at PR #15739 and taken on #15743: option B, move the `pages.*` +// parity verdict OFF that roster and onto this file, then translate the three +// labels. Both halves are below -- the moved verdict and its red-shape controls +// in the next block, the nine delivered translation units in the one after. The +// three pages now DO carry a `pages.*` entry in all four shipped locales, and +// the assertion two below still holds: `label` remains the only key the +// extractor offers them, because nothing about their SHAPE changed. /** Every page the platform's own `pages` barrel exports, as the plugins take them. */ const RECORD_PAGES: Array> = Object.values( @@ -648,3 +654,393 @@ describe('shipped platform record pages -- i18n ownership (#14817)', () => { expect(incomplete).toEqual([]); }); }); + +// ─── The `pages.*` default-locale parity verdict (#8764, moved here by #15743) ─ +// +// This verdict used to live in `packages/cli/scripts/check-app-nav-i18n.mjs`, +// beside that gate's `CONTRIBUTORS` roster. #15743 ruled it out of there +// (option B, ruled at PR #15739) and into this file. The reason is not tidiness +// — it is the roster's own defect class: +// +// > the defect class this whole card is about is ONE roster silently serving +// > TWO populations — exactly what produced the ambiguous 0 +// +// `CONTRIBUTORS` is a NAV roster: every entry must land at least one nav id or +// the gate fails, and it boots each contributor to get them. The pages rode +// along because the same manifests happened to carry `pages:`. Those two +// populations are NOT the same set, and the measurement that settles it: the +// booted composition contains FOUR pages (`cloud_connection_settings`, +// `connect_agent`, `marketplace_installed`, `sys_position_detail`) while the +// platform ships SIX. `sys_user_detail` and `sys_organization_detail` come from +// `@objectstack/plugin-auth`, which cannot join a NAV roster at all — +// `new AuthPlugin({})` refuses to boot ("secret is required") and its only nav +// contribution (`nav_sso_providers`) is conditional, so it can never satisfy the +// at-least-one-nav-id invariant. A bundle entry for either page therefore drew +// the gate's ORPHAN verdict, "the booted composition contains no page by that +// name" — true about that composition, false about the platform. +// +// ⛔ Option A — splitting the roster into a NAV population and a PAGE +// population, booting `plugin-auth` with a test secret, and exempting pages +// from the nav-id invariant — was refused on #15743 and is not to be revived: +// it makes one roster serve two populations explicitly, with a credential +// fixture and a hand-carved exemption, which is the original defect with better +// documentation. +// +// Here the population is READ, not rostered: the contributing plugins' UI +// bundles plus the `@objectstack/platform-objects/pages` barrel, neither of +// which needs a boot, a credential or a nav id. That is a strict superset of +// what the gate could reach (six pages against four), so the move ADDS the two +// pages the roster structurally could not judge rather than trading coverage +// for placement. +// +// What is preserved verbatim from the gate: the three finding kinds, because +// their REMEDIES differ — +// +// drift the bundle serves a string the source no longer says. Fix the +// bundle (and the other locales, now stale too). +// orphan the bundle names a page no shipped population contains. The +// ANTI-VACUITY half: without it, renaming a page silently reduces +// this assertion to comparing nothing, and a parity gate that +// cannot fail is the exact defect class #8764 is about. +// no-source the bundle declares a key the page has nothing to overlay — a +// phantom key that translates nothing (ADR-0078's shape). Delete +// the key or restore the field at the source. +// +// …and the RED-SHAPE controls the gate ran under `--self-test`, which are now +// ordinary test cases below. A verdict moved without them would be a verdict +// nobody has seen fail. + +/** The component type whose `title` / `subtitle` the page bundle addresses. */ +const PAGE_HEADER_COMPONENT = 'page:header'; + +interface PageSourceCopy { + label?: string; + description?: string; + title: string[]; + subtitle: string[]; +} + +/** + * The source literals a page offers, keyed the way the bundle addresses them. + * The mapping is not invented here — it is the one `translatePage` implements + * and `TranslationDataSchema.pages` documents: + * + * pages..label → the page document's own `label` + * pages..description → the page document's own `description` + * pages..title → every `page:header`'s `properties.title` + * pages..subtitle → every `page:header`'s `properties.subtitle` + * + * `title`/`subtitle` are ARRAYS because the resolver overlays every + * `page:header` in the regions, not the first one — so parity has to hold for + * all of them or the bundle is right about one header and wrong about another. + */ +const pageSourceCopy = (page: Record | undefined): PageSourceCopy => { + const headers: Array> = []; + for (const region of page?.regions ?? []) { + for (const component of region?.components ?? []) { + if (component?.type === PAGE_HEADER_COMPONENT) headers.push(component?.properties ?? {}); + } + } + const strings = (key: string): string[] => + headers.map((h) => h?.[key]).filter((v): v is string => typeof v === 'string'); + return { + label: typeof page?.label === 'string' ? page.label : undefined, + description: typeof page?.description === 'string' ? page.description : undefined, + title: strings('title'), + subtitle: strings('subtitle'), + }; +}; + +type PageFindingKind = 'drift' | 'orphan' | 'no-source'; +interface PageFinding { + kind: PageFindingKind; + path: string; + source?: string; + served?: string; +} + +/** + * Every way the default-locale `pages.*` section can disagree with the page + * metadata it copies. + * + * ⚠️ `title` implements the resolver's fallback: `pages..title` defaults + * to `pages..label` when omitted. That is not a detail — it is a real + * drift path. A header `title` edited to differ from the page `label` would be + * silently overwritten by the label in every locale, `en` included, with + * nothing else in the repo comparing the two. + */ +const defaultLocalePageDrift = ( + pageTranslations: Record | undefined, + sourceByName: Map, +): PageFinding[] => { + const findings: PageFinding[] = []; + const add = (kind: PageFindingKind, path: string, source?: string, served?: string) => + findings.push({ kind, path, source, served }); + + for (const [name, entry] of Object.entries(pageTranslations ?? {})) { + const source = sourceByName.get(name); + if (!source) { + add('orphan', `pages.${name}`); + continue; + } + + // The page document's own fields. + for (const attr of ['label', 'description'] as const) { + const served = entry?.[attr]; + if (typeof served !== 'string') continue; // an undeclared key makes no claim + if (typeof source[attr] !== 'string') { add('no-source', `pages.${name}.${attr}`, undefined, served); continue; } + if (served !== source[attr]) add('drift', `pages.${name}.${attr}`, source[attr], served); + } + + // The `page:header` copy. + const explicitTitle = typeof entry?.title === 'string' ? entry.title : undefined; + const servedTitle = explicitTitle ?? (typeof entry?.label === 'string' ? entry.label : undefined); + const titlePath = `pages.${name}.${explicitTitle !== undefined ? 'title' : 'label'}`; + if (servedTitle !== undefined) { + // An explicit `title` with no header to land on is a phantom key. The + // `label` FALLBACK is not — `label` has already been judged above against + // the page's own field, and a page with no header title is not a page the + // fallback makes a claim about. + if (source.title.length === 0) { + if (explicitTitle !== undefined) add('no-source', titlePath, undefined, explicitTitle); + } else { + for (const t of source.title) if (servedTitle !== t) add('drift', titlePath, t, servedTitle); + } + } + const servedSubtitle = entry?.subtitle; + if (typeof servedSubtitle === 'string') { + if (source.subtitle.length === 0) add('no-source', `pages.${name}.subtitle`, undefined, servedSubtitle); + else for (const s of source.subtitle) if (servedSubtitle !== s) add('drift', `pages.${name}.subtitle`, s, servedSubtitle); + } + } + return findings; +}; + +/** + * Every page the platform ships that the `pages.*` bundle section can address, + * with the package that authors its literals — so a failure names where to go. + * + * Read from the two carriers rather than listed: the capability plugins' UI + * bundles (all of their pages, not just the first) and the platform's own pages + * barrel. A fourth page joins this verdict by existing. + */ +const ALL_PAGE_SOURCES: Array<{ page: Record; authoredBy: string }> = [ + ...([ + [MARKETPLACE_INSTALLED_UI_BUNDLE, '@objectstack/cloud-connection'], + [CLOUD_CONNECTION_UI_BUNDLE, '@objectstack/cloud-connection'], + [CONNECT_AGENT_UI_BUNDLE, '@objectstack/mcp'], + ] as Array<[{ pages?: Array> }, string]>).flatMap(([bundle, authoredBy]) => + (bundle.pages ?? []).map((page) => ({ page, authoredBy })), + ), + ...RECORD_PAGES.map((page) => ({ page, authoredBy: '@objectstack/platform-objects/pages' })), +]; + +const SOURCE_BY_NAME = new Map( + ALL_PAGE_SOURCES.map(({ page }) => [page.name as string, pageSourceCopy(page)]), +); +const AUTHORED_BY = new Map( + ALL_PAGE_SOURCES.map(({ page, authoredBy }) => [page.name as string, authoredBy]), +); + +describe('`pages.*` default-locale parity (#8764, moved off the nav roster by #15743)', () => { + it('judges a population that covers every page the shipped bundle addresses', () => { + // The anti-vacuity floor, stated before the verdict runs. A verdict over an + // empty or shrunken population is the ambiguous `0` this whole card is + // about: every loop below would be satisfied and report success. + expect(SOURCE_BY_NAME.size).toBeGreaterThanOrEqual(6); + expect([...SOURCE_BY_NAME.keys()].sort()).toEqual( + expect.arrayContaining([ + 'cloud_connection_settings', + 'connect_agent', + 'marketplace_installed', + 'sys_organization_detail', + 'sys_position_detail', + 'sys_user_detail', + ]), + ); + // …and it is strictly larger than what the booted nav composition reached, + // which is the measured reason the verdict moved: `plugin-auth` cannot join + // a NAV roster, so those two pages had no judge at all. + expect(SOURCE_BY_NAME.has('sys_user_detail')).toBe(true); + expect(SOURCE_BY_NAME.has('sys_organization_detail')).toBe(true); + }); + + it('holds the `en` bundle in verbatim parity with the page sources', () => { + const enPages = pagesOf(EN); + // Absence is loud: an `en` section that lost its entries would make the + // verdict below compare nothing at all. + expect(Object.keys(enPages).length).toBeGreaterThanOrEqual(6); + + const findings = defaultLocalePageDrift(enPages, SOURCE_BY_NAME).map((f) => ({ + ...f, + authoredBy: AUTHORED_BY.get(f.path.split('.')[1]) ?? '(no page by that name)', + })); + expect(findings).toEqual([]); + }); + + it('carries a `pages.*` entry for every page in the population', () => { + // The reverse of the orphan direction: the bundle must not go SILENT on a + // page either. An unaddressed page renders its authored literal in every + // locale, which is exactly the debt #15743 was filed for. + const enPages = pagesOf(EN); + const unaddressed = [...SOURCE_BY_NAME.keys()].filter((name) => !enPages[name]).sort(); + expect(unaddressed).toEqual([]); + }); + + // ── The RED-SHAPE controls. These were `--self-test` cases on the gate; a + // verdict moved without them is a verdict nobody has seen fail. + const SAMPLE_PAGE = { + name: 'marketplace_installed', + label: 'Installed Apps', + regions: [ + { + name: 'header', + components: [ + { + type: 'page:header', + properties: { + title: 'Installed Apps', + subtitle: "Marketplace packages currently installed into this runtime's kernel.", + }, + }, + ], + }, + { name: 'main', components: [{ type: 'marketplace:installed-list', properties: {} }] }, + ], + }; + const SAMPLE_SOURCES = new Map([[SAMPLE_PAGE.name, pageSourceCopy(SAMPLE_PAGE)]]); + const IN_PARITY = { + marketplace_installed: { + label: 'Installed Apps', + subtitle: "Marketplace packages currently installed into this runtime's kernel.", + }, + }; + + it('CONTROL: reads the page label and the header copy out of the regions', () => { + const copy = pageSourceCopy(SAMPLE_PAGE); + expect({ label: copy.label, title: copy.title, subtitles: copy.subtitle.length }) + .toEqual({ label: 'Installed Apps', title: ['Installed Apps'], subtitles: 1 }); + // A bare page yields no header copy rather than throwing. + expect(pageSourceCopy({ name: 'x' }).title).toEqual([]); + }); + + it('CONTROL: a bundle in parity reports nothing', () => { + expect(defaultLocalePageDrift(IN_PARITY, SAMPLE_SOURCES)).toEqual([]); + }); + + it('CONTROL: a source string edited in another package is reported as drift', () => { + const findings = defaultLocalePageDrift( + { marketplace_installed: { ...IN_PARITY.marketplace_installed, subtitle: 'Packages installed into this kernel.' } }, + SAMPLE_SOURCES, + ); + expect(findings.map((f) => ({ kind: f.kind, path: f.path }))).toEqual([ + { kind: 'drift', path: 'pages.marketplace_installed.subtitle' }, + ]); + // The verdict carries BOTH strings — the whole point is that a reader can + // see which side moved without opening two packages. + expect({ + source: findings[0]?.source?.startsWith('Marketplace packages'), + served: findings[0]?.served?.startsWith('Packages installed'), + }).toEqual({ source: true, served: true }); + }); + + it('CONTROL: the title fallback catches a header edited away from the label', () => { + // The bundle declares `label` only, so `label` is what serves the header + // title. A header title edited to differ from the page label is drift + // NOTHING else in the repo compares. + const headerEdited = new Map([[ + 'marketplace_installed', + pageSourceCopy({ + ...SAMPLE_PAGE, + regions: [{ + name: 'header', + components: [{ + type: 'page:header', + properties: { title: 'Installed Packages', subtitle: IN_PARITY.marketplace_installed.subtitle }, + }], + }], + }), + ]]); + expect( + defaultLocalePageDrift(IN_PARITY, headerEdited).some((f) => f.kind === 'drift' && f.source === 'Installed Packages'), + ).toBe(true); + }); + + it('CONTROL: an entry with no page in the population is an orphan, never parity', () => { + const findings = defaultLocalePageDrift(IN_PARITY, new Map()); + expect(findings.map((f) => ({ kind: f.kind, path: f.path }))).toEqual([ + { kind: 'orphan', path: 'pages.marketplace_installed' }, + ]); + }); + + it('CONTROL: a key the page cannot carry is a phantom key, but the label fallback is not', () => { + const phantom = defaultLocalePageDrift( + { marketplace_installed: { ...IN_PARITY.marketplace_installed, description: 'Anything' } }, + SAMPLE_SOURCES, + ); + expect(phantom.some((f) => f.kind === 'no-source' && f.path.endsWith('.description'))).toBe(true); + // …but a headerless page must raise nothing from the `label` fallback, or + // every one of the three record pages would report a finding it cannot act + // on: they all author `regions: []`. + const headerless = defaultLocalePageDrift( + { p: { label: 'P' } }, + new Map([['p', pageSourceCopy({ name: 'p', label: 'P' })]]), + ); + expect(headerless).toEqual([]); + }); +}); + +describe('platform record page labels are translated in every shipped locale (#15743)', () => { + /** + * The debt this card was filed for: `User` / `Organization` / `Position` are + * page-level `label`s on the three record pages, they are the ONLY keys the + * extractor reaches on those pages (the block above pins that), and they + * rendered English in every locale — 3 keys × 3 translated locales = 9 units. + * + * ⚠️ This is a coverage claim of its own, deliberately NOT a restatement of + * the key-set assertion further up this file, which compares page-name SETS + * and says nothing about the copy inside an entry. It is scoped to these three + * pages: requiring every leaf of every page to differ from `en` would be a + * different, much larger claim. + * + * It reads `SetupAppTranslations`, which is the SERVED bundle — the three + * translated locales pass through `withSourceFallback` (#8765 Option B), so a + * wrong recorded source hash would serve the English source here and red this + * assertion rather than shipping a silent regression. + */ + const RECORD_PAGE_NAMES = ['sys_user_detail', 'sys_organization_detail', 'sys_position_detail']; + const TRANSLATED_LOCALES = SHIPPED_LOCALES.filter((locale) => locale !== EN); + + it('has a real, non-English label for each of the three in each translated locale', () => { + expect(TRANSLATED_LOCALES.length).toBeGreaterThanOrEqual(3); + + const english = pagesOf(EN); + const untranslated: Array<{ locale: string; page: string; label: unknown }> = []; + for (const locale of TRANSLATED_LOCALES) { + const served = pagesOf(locale); + for (const name of RECORD_PAGE_NAMES) { + const label = served[name]?.label; + if (typeof label !== 'string' || label.length === 0 || label === english[name]?.label) { + untranslated.push({ locale, page: name, label }); + } + } + } + expect(untranslated).toEqual([]); + }); + + it('translates the page label end to end through `translatePage`', () => { + // The chain the reader actually meets: page metadata in, localized label + // out. Asserting the bundle alone would not prove the resolver reads it. + for (const page of RECORD_PAGES) { + // Snapshot by value up front — comparing two live reads of the same + // object afterwards could not detect a mutation. + const before = page.label; + const translated = translatePage(page as any, SetupAppTranslations, { locale: 'zh-CN' }); + expect({ page: page.name, changed: translated.label !== before }) + .toEqual({ page: page.name, changed: true }); + // The shared page object is a module-level singleton the kernel + // registers once — it must not be mutated. + expect({ page: page.name, label: page.label }).toEqual({ page: page.name, label: before }); + } + }); +}); diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 5a4ab599cd..d8a8bf8662 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -45,10 +45,14 @@ * by `SETUP_NAV_CONTRIBUTIONS` and by capability plugins, so a bundle * generated from a static walk of `SETUP_APP` would be structurally * incomplete — regenerating over it would DELETE 40 live nav - * translations per locale. Their gate is `pnpm check:app-nav-i18n` - * (`packages/cli/scripts/check-app-nav-i18n.mjs`), which boots the real - * composition and judges the MERGED app — not the bundle-drift gate, and - * not the coverage ratchet. + * translations per locale. The gate for `apps` / `dashboards` is + * `pnpm check:app-nav-i18n` (`packages/cli/scripts/check-app-nav-i18n.mjs`), + * which boots the real composition and judges the MERGED app — not the + * bundle-drift gate, and not the coverage ratchet. `pages` is judged by + * `packages/cli/test/platform-page-i18n-parity.test.ts` instead: it moved + * off that roster on #15743, because a NAV roster (every entry must land a + * nav id, and `@objectstack/plugin-auth` cannot) is structurally unable to + * reach two of the six pages the platform ships. * * This paragraph used to end "Their gate is the coverage ratchet * (`scripts/check-i18n-coverage.mjs`), baselined at 0 for this package", diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 6873d60cbb..6c813a1f39 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -253,5 +253,14 @@ export const en: TranslationData = { 'Give any MCP-capable AI client governed access to this environment — ' + "every call runs under the caller's own permissions and row-level security.", }, + + // The platform's own record pages (`@objectstack/platform-objects/pages`, + // contributed by plugin-auth and plugin-security). Their page-level `label` + // is the only key the extractor reaches -- everything else on them is + // authored as an inline locale map under `slots.*` (#14817). English + // mirrors the literal in the page metadata, as above. + sys_user_detail: { label: 'User' }, + sys_organization_detail: { label: 'Organization' }, + sys_position_detail: { label: 'Position' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 4f28b94321..37495896b2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -160,4 +160,7 @@ export const esESSourceHashes: Readonly> = { 'pages.cloud_connection_settings.subtitle': 'f5cdd253e8ffef9f', 'pages.connect_agent.label': 'eeb174613510e87d', 'pages.connect_agent.subtitle': '2d0f9a000824e78a', + 'pages.sys_user_detail.label': '6c0d22a931e235bd', + 'pages.sys_organization_detail.label': '3e55836156e1c1de', + 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index a0f1b8c64e..164b5e3d85 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -174,5 +174,10 @@ export const esES: TranslationData = { 'Concede a cualquier cliente de IA compatible con MCP acceso controlado a este entorno: ' + 'cada llamada se ejecuta con los permisos propios de quien la realiza y con seguridad a nivel de fila.', }, + + // Platform record pages (#15743) -- see `en.ts` for the key set. + sys_user_detail: { label: 'Usuario' }, + sys_organization_detail: { label: 'Organización' }, + sys_position_detail: { label: 'Posición' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index 6444edd9f1..ac2405f3e6 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -160,4 +160,7 @@ export const jaJPSourceHashes: Readonly> = { 'pages.cloud_connection_settings.subtitle': 'f5cdd253e8ffef9f', 'pages.connect_agent.label': 'eeb174613510e87d', 'pages.connect_agent.subtitle': '2d0f9a000824e78a', + 'pages.sys_user_detail.label': '6c0d22a931e235bd', + 'pages.sys_organization_detail.label': '3e55836156e1c1de', + 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index e2b1fdcac6..c6f50c8c0f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -172,5 +172,10 @@ export const jaJP: TranslationData = { subtitle: 'MCP 対応の AI クライアントにこの環境への統制されたアクセスを許可します。すべての呼び出しは、呼び出し元自身の権限と行レベルセキュリティのもとで実行されます。', }, + + // Platform record pages (#15743) -- see `en.ts` for the key set. + sys_user_detail: { label: 'ユーザー' }, + sys_organization_detail: { label: '組織' }, + sys_position_detail: { label: 'ポジション' }, }, }; diff --git a/packages/platform-objects/src/apps/translations/source-hash.ts b/packages/platform-objects/src/apps/translations/source-hash.ts index 630ea615d1..68fbf22975 100644 --- a/packages/platform-objects/src/apps/translations/source-hash.ts +++ b/packages/platform-objects/src/apps/translations/source-hash.ts @@ -71,9 +71,13 @@ * `app-nav-translation-parity.test.ts` fails the build when `en.ts` stops * matching the declared `SETUP_APP` / `STUDIO_APP` / `ACCOUNT_APP` / * `SystemOverviewDashboard` literals verbatim. - * - `pages.*` — `check:app-nav-i18n` compares the `en` copy against the - * composed page metadata (those sources live in `@objectstack/cloud-connection` - * and `@objectstack/mcp`, which this package does not and must not depend on). + * - `pages.*` — `packages/cli/test/platform-page-i18n-parity.test.ts` compares + * the `en` copy against the page metadata. Those sources live in + * `@objectstack/cloud-connection`, `@objectstack/mcp` and this package's own + * `pages` barrel; the first two are packages this one does not and must not + * depend on, which is why the assertion sits in `packages/cli`. It moved + * there from `check:app-nav-i18n` on #15743 — that gate's roster is a NAV + * roster and could not reach the two `plugin-auth` pages at all. * * So hashing `en` transitively hashes the declared source, and this module needs * to import nothing but the bundle sitting next to it. diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index 78e8a43338..91fd98662c 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -160,4 +160,7 @@ export const zhCNSourceHashes: Readonly> = { 'pages.cloud_connection_settings.subtitle': 'f5cdd253e8ffef9f', 'pages.connect_agent.label': 'eeb174613510e87d', 'pages.connect_agent.subtitle': '2d0f9a000824e78a', + 'pages.sys_user_detail.label': '6c0d22a931e235bd', + 'pages.sys_organization_detail.label': '3e55836156e1c1de', + 'pages.sys_position_detail.label': '9a24c02e441eb0b4', }; diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index e841295b10..e30c4a1043 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -180,5 +180,10 @@ export const zhCN: TranslationData = { label: '连接智能体', subtitle: '让任意支持 MCP 的 AI 客户端受控访问此环境——每次调用都在调用者自身的权限与行级安全范围内执行。', }, + + // Platform record pages (#15743) -- see `en.ts` for the key set. + sys_user_detail: { label: '用户' }, + sys_organization_detail: { label: '组织' }, + sys_position_detail: { label: '岗位' }, }, };