From 40d7c7b586acc5127a611095587421d4a0cf4f9e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 08:48:38 +0000 Subject: [PATCH] =?UTF-8?q?fix(platform-objects,cli):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=20Setup=20=E8=BF=90=E8=A1=8C=E6=97=B6=E8=B4=A1=E7=8C=AE?= =?UTF-8?q?=E5=AF=BC=E8=88=AA=E7=9A=84=E5=9B=9B=E8=AF=AD=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8A=8A=E8=A6=86=E7=9B=96=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E7=A7=BB=E5=88=B0=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=20app=20?= =?UTF-8?q?=E5=85=83=E6=95=B0=E6=8D=AE=E4=B8=8A=20(#5750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh 界面下 Setup 侧边栏 51 条里有 4 条仍是英文,服务端发出的合并后 app 元数据 本身就是英文。两处成因:`nav_packages` 的翻译写在了 `apps.studio.navigation` 名下(Setup 查的是 `apps.setup.navigation`,落空回退到作者的英文字面量),另外 三条 —— `nav_approval_delegations` / `nav_webhooks` / `nav_http_deliveries` —— 在任何 locale 都不存在。 真正值得记一笔的是两道闸门之间的交接:Setup 的导航由 `SETUP_NAV_CONTRIBUTIONS` 和各能力插件在运行时贡献(ADR-0029 D7),静态走查看不到;而 parity test 注释说 「交给 coverage ratchet」,extract config 注释也说「交给 coverage ratchet」, 那道 ratchet 走的却是静态配置。它记的 0 不是「查过了,干净」,而是「没查到这里」, 整个过程报绿。 新增 `pnpm check:app-nav-i18n`(`packages/cli/scripts/check-app-nav-i18n.mjs`, 已接入 lint.yml):启动真实组合,按 `/api/v1/meta/app` 同一条 `applyNavContributions` 路径合并导航,断言每个合并后的 nav id 在每个 locale 都有 label。它同时在任一声明 的贡献方一条 nav id 都没落地时报红 —— 合并出的 id 少了就是被检查的 id 少了, 这个方向会让闸门变绿而不是变红。 顺带被新闸门查出、单 locale 复现看不到的另外四条:`nav_capabilities` / `nav_settings_localization` / `nav_settings_company` / `nav_datasources` 只在 zh-CN 有翻译,ja-JP 与 es-ES 同样显示英文。八条现已在四个 locale 全部补齐。 两处把责任交给 ratchet 的注释已改写为指向真正的所有者。插件 nav 的 `label` 仍 保持裸英文字面量,本 PR 不动这个授权契约。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .changeset/setup-nav-runtime-i18n-coverage.md | 54 ++ .github/workflows/lint.yml | 18 + package.json | 1 + packages/cli/package.json | 3 +- packages/cli/scripts/check-app-nav-i18n.mjs | 498 ++++++++++++++++++ .../scripts/i18n-extract.config.ts | 19 +- .../app-nav-translation-parity.test.ts | 17 +- .../src/apps/translations/en.ts | 18 + .../src/apps/translations/es-ES.ts | 13 + .../src/apps/translations/ja-JP.ts | 13 + .../src/apps/translations/zh-CN.ts | 16 + scripts/check-i18n-coverage.mjs | 15 + 12 files changed, 680 insertions(+), 5 deletions(-) create mode 100644 .changeset/setup-nav-runtime-i18n-coverage.md create mode 100644 packages/cli/scripts/check-app-nav-i18n.mjs diff --git a/.changeset/setup-nav-runtime-i18n-coverage.md b/.changeset/setup-nav-runtime-i18n-coverage.md new file mode 100644 index 0000000000..6fe14481d2 --- /dev/null +++ b/.changeset/setup-nav-runtime-i18n-coverage.md @@ -0,0 +1,54 @@ +--- +"@objectstack/platform-objects": patch +--- + +fix(platform-objects): translate the Setup app's runtime-contributed navigation, and gate it on the merged app instead of a static walk (#5750) + +Under `zh-CN`, four of the Setup app's ~50 sidebar entries rendered in English — +`Packages`, `Delegations (OOO)`, `Webhooks`, `HTTP Deliveries` — and it was not a +client-side fallback: the server's own merged `app` metadata carried the English +literals. Sitting in a screen of Chinese menu items, they read like words that +were simply never meant to be translated. + +Two different causes, both now fixed: + +- **`nav_packages` was translated in the wrong app's namespace.** A + `nav_packages: { label: '软件包' }` existed under `apps.studio.navigation`. + Setup contributes an entry with the same id (package administration is an + operator concern, ADR-0084) and looks it up under + `apps.setup.navigation.nav_packages` — a different subtree, so the lookup + missed and the author's `'Packages'` literal won. Both entries are legitimate; + the Setup one has been added and the Studio one left alone. +- **The other three had no translation anywhere.** `nav_approval_delegations` + (`@objectstack/plugin-approvals`), `nav_webhooks` and `nav_http_deliveries` + (`@objectstack/plugin-webhooks`) are contributed at runtime by the capability + plugins that own the objects, and no locale file carried a label for them. + +Four more were found by the new gate below, invisible to the one-locale browser +session that reported this: `nav_capabilities`, `nav_settings_localization`, +`nav_settings_company` and `nav_datasources` were translated in `zh-CN` **only**, +so `ja-JP` and `es-ES` menus showed English there too. All eight ids are now +labelled in all four locales (`en`, `zh-CN`, `ja-JP`, `es-ES`). + +**Why nothing caught it, which is the part worth keeping.** The Setup app is a +shell of empty group anchors whose entries arrive at runtime (ADR-0029 D7), so a +static walk sees none of them. Both existing gates knew this and each named the +*other* as the owner: `app-nav-translation-parity.test.ts` excluded Setup and +deferred to "the coverage ratchet", while `platform-objects`' extract config +deferred the same labels to that ratchet "baselined at 0 for this package". The +ratchet runs `os lint` over **static** stack configs, so its 0 meant "not looked +at here", not "checked, clean" — and it reported OK the whole time. + +A new gate closes the handoff — `pnpm check:app-nav-i18n` +(`packages/cli/scripts/check-app-nav-i18n.mjs`, wired into `lint.yml`). It boots +the real composition, merges the navigation contributions through the same +`applyNavContributions` path the `/api/v1/meta/app` read uses, and asserts every +merged nav id carries a label in every locale the platform bundle declares — so +the next plugin-contributed entry cannot leak the same way. It also fails when a +declared contributor lands no nav id at all, because fewer merged ids means +fewer ids checked: a contributor that silently stops contributing would +otherwise make the gate greener rather than redder. The two comments that +delegated to the ratchet now say what actually owns these labels. + +No authoring change: plugin nav `label` values stay plain English literals, and +translations continue to live in `apps.setup.navigation` in this package. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 701ccca281..bac44948a0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1003,6 +1003,24 @@ jobs: - name: Check no new untranslated declared labels run: pnpm check:i18n-coverage + # The THIRD i18n question, and the one neither step above can answer + # (#5750). Both of them read STATIC declarations; the Setup app declares a + # shell of empty group anchors and gets its ~50 menu entries at RUNTIME + # from SETUP_NAV_CONTRIBUTIONS and from the capability plugins that own the + # underlying objects (ADR-0029 D7). So those labels were covered by + # nothing: the extract config deferred them to the ratchet, the parity test + # deferred them to the ratchet, and the ratchet walks static configs. Four + # of them were untranslated in `zh-CN` while every gate reported green. + # + # This one boots the real composition, merges the contributions the same + # way the `/api/v1/meta/app` read path does, and asserts every merged nav + # id carries a label in every locale the platform bundle declares. + # + # Imports the BUILT output of ten workspace packages, so it belongs here + # with the other post-build consumer gates. + - name: Check runtime-merged app navigation is translated in every locale + run: pnpm check:app-nav-i18n + # Seed the shared Turbo cache from main only (see the restore step above). - name: Save Turbo cache (main only) if: always() && github.event_name == 'push' diff --git a/package.json b/package.json index 13868f7b4a..57c63b619c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "i18n:extract": "tsx packages/cli/bin/run-dev.js i18n extract packages/platform-objects/scripts/i18n-extract.config.ts --locales=zh-CN,ja-JP,es-ES --fill=default --out=packages/platform-objects/src/apps/translations", "check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs", "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs --self-test && node scripts/check-i18n-coverage.mjs", + "check:app-nav-i18n": "pnpm --filter @objectstack/cli run check:app-nav-i18n", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:docs-audit-scope": "node scripts/docs-audit/affected-docs.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs --self-test && node scripts/docs-audit/check-audit-scope.mjs", diff --git a/packages/cli/package.json b/packages/cli/package.json index 92fec90797..71d149c8f6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -12,7 +12,8 @@ "build": "if [ -n \"$OS_SKIP_DTS\" ]; then tsc -p tsconfig.build.json --noCheck --declaration false --declarationMap false; else tsc -p tsconfig.build.json; fi", "dev": "tsc -p tsconfig.build.json --watch", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:app-nav-i18n": "node scripts/check-app-nav-i18n.mjs --self-test && node scripts/check-app-nav-i18n.mjs" }, "keywords": [ "objectstack", diff --git a/packages/cli/scripts/check-app-nav-i18n.mjs b/packages/cli/scripts/check-app-nav-i18n.mjs new file mode 100644 index 0000000000..2bb9ab3df0 --- /dev/null +++ b/packages/cli/scripts/check-app-nav-i18n.mjs @@ -0,0 +1,498 @@ +#!/usr/bin/env node +// check-app-nav-i18n — translation coverage for the Setup app's navigation, +// judged on the RUNTIME-MERGED app metadata rather than on a static walk. +// +// --------------------------------------------------------------------------- +// Why this gate exists (#5750): a handoff nobody stood on +// --------------------------------------------------------------------------- +// The Setup app (`setup.app.ts`) is a shell of empty group anchors (ADR-0029 +// D7). Every menu entry arrives at RUNTIME, from `SETUP_NAV_CONTRIBUTIONS` and +// from the capability plugins that own the underlying objects. So neither of +// the two existing i18n gates could see those labels, and both said so while +// naming the OTHER one as the owner: +// +// • `app-nav-translation-parity.test.ts` walks `STUDIO_APP` / `ACCOUNT_APP` +// statically and excluded Setup, deferring to "the coverage ratchet". +// • `scripts/check-i18n-coverage.mjs` runs `os lint` over STATIC stack +// configs; `platform-objects`' extract config deferred the same labels to +// that ratchet, "baselined at 0 for this package". +// +// The ratchet's 0 was never "checked, clean" — it was "not looked at here". +// Measured on `origin/main` at the time of writing, four ids contributed at +// runtime carried no `zh-CN` label at all (`nav_packages`, +// `nav_approval_delegations`, `nav_webhooks`, `nav_http_deliveries`) and the +// gate reported OK. Three more (`nav_capabilities`, `nav_settings_localization`, +// `nav_settings_company`) plus `nav_datasources` were translated in `zh-CN` +// ONLY, which the browser repro that found this could not see because it ran +// under one locale. +// +// So this gate boots the real composition, reads the app back through the same +// `applyNavContributions` merge the REST `/api/v1/meta/app` path uses, and +// asserts every merged nav id carries a label in every locale the platform +// bundle declares. It is the repro script from #5750, run in CI. +// +// node packages/cli/scripts/check-app-nav-i18n.mjs +// node packages/cli/scripts/check-app-nav-i18n.mjs --self-test # no build +// +// From the repo root that is `pnpm check:app-nav-i18n` (which runs both). +// +// --------------------------------------------------------------------------- +// It lives in `packages/cli` on purpose +// --------------------------------------------------------------------------- +// "Who serves this path" is a question about the composed runtime, not about +// which plugin declares what (AGENTS.md, Route & surface ownership). `cli` is +// the composition root — the package `os dev` / `os serve` assemble from — and +// the ONLY workspace package that depends on all eleven Setup nav contributors +// at once. A gate in `platform-objects` could not import the plugins (they +// depend on it, not the other way round) and would be measuring the shell. +// +// --------------------------------------------------------------------------- +// What this gate deliberately does NOT claim +// --------------------------------------------------------------------------- +// 1. A composition is a SUBSET of what can be contributed. Some entries are +// conditional — `plugin-auth` contributes `nav_sso_providers` only when an +// external IdP is wired — so a label this run never merged is a label this +// run never judged. `CONTRIBUTORS` below is therefore explicit, and each +// entry must land at least one nav id or the run fails: a contributor that +// silently stops contributing must not read as "everything is translated". +// 2. No reverse direction. `app-nav-translation-parity.test.ts` asserts Studio +// carries no translation for a removed nav id; the same assertion here +// would delete the labels of conditionally-contributed entries, because a +// gated-off contribution is indistinguishable from a dead key when all you +// have is one runtime composition. The dead `apps.setup.navigation` keys +// that exist today are tracked separately rather than removed on a verdict +// this gate cannot honestly reach. +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CLI_ROOT = join(HERE, '..'); + +/** The app whose navigation is assembled at runtime. Setup is the only one. */ +const APP_NAME = 'setup'; + +// --------------------------------------------------------------------------- +// Pure verdict helpers — driven by `--self-test` with recorded samples, so each +// is proven able to go RED without a build and without booting anything. +// --------------------------------------------------------------------------- + +/** Every nav id in a navigation tree, depth-first (groups included). */ +export function collectNavIds(navigation) { + const out = []; + const walk = (items) => { + for (const raw of items ?? []) { + const item = raw ?? {}; + if (typeof item.id === 'string' && item.id) out.push(item.id); + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk(Array.isArray(navigation) ? navigation : []); + return out; +} + +/** + * The ids a locale has no usable label for. A key present with an empty or + * non-string label is missing, not present: the console falls back to the + * author's English literal either way, which is the exact symptom #5750 + * reported. + */ +export function missingLabels(ids, navTranslations) { + const nav = navTranslations ?? {}; + return ids.filter((id) => { + const label = nav[id]?.label; + return typeof label !== 'string' || label.trim() === ''; + }); +} + +/** + * Contributors that landed no nav id at all. This is the anti-false-green half: + * fewer merged ids means fewer ids checked, so a plugin whose registration + * silently no-ops would make this gate GREENER, not redder (#4690's shape). + */ +export function contributorsWithNoNavIds(contributions) { + return contributions.filter((c) => c.ids.length === 0).map((c) => c.source); +} + +/** Render one locale's shortfall with the source that declared each id. */ +function renderMissing(locale, missing, declaredBy) { + const lines = missing.map((id) => { + const d = declaredBy.get(id); + const literal = d?.label ? ` — author's literal ${JSON.stringify(d.label)}` : ''; + const from = d?.source ? ` contributed by ${d.source}` : ' contributed by (unknown)'; + return ` ${id}${from}${literal}`; + }); + return ( + `apps.${APP_NAME}.navigation — locale \`${locale}\` has no label for ` + + `${missing.length} runtime-merged nav id(s):\n${lines.join('\n')}` + ); +} + +// --------------------------------------------------------------------------- +// The composition. EXPLICIT, never discovered: a contributor that drops out of +// this list must do so in a diff someone reads (Route & surface ownership §2). +// Every entry must land at least one `setup` nav id. +// --------------------------------------------------------------------------- + +const CONTRIBUTORS = [ + { + source: '@objectstack/setup (SETUP_APP + SETUP_NAV_CONTRIBUTIONS)', + async load() { + const { createSetupAppPlugin } = await import('@objectstack/setup'); + return { plugin: createSetupAppPlugin() }; + }, + }, + { + source: '@objectstack/plugin-security', + async load() { + const { SecurityPlugin } = await import('@objectstack/plugin-security'); + return { plugin: new SecurityPlugin({}) }; + }, + }, + { + source: '@objectstack/plugin-sharing', + async load() { + const { SharingServicePlugin } = await import('@objectstack/plugin-sharing'); + return { plugin: new SharingServicePlugin({}) }; + }, + }, + { + source: '@objectstack/plugin-approvals', + async load() { + const { ApprovalsServicePlugin } = await import('@objectstack/plugin-approvals'); + return { plugin: new ApprovalsServicePlugin({ disableService: true }) }; + }, + }, + { + source: '@objectstack/plugin-audit', + async load() { + const { AuditPlugin } = await import('@objectstack/plugin-audit'); + return { plugin: new AuditPlugin() }; + }, + }, + { + source: '@objectstack/plugin-webhooks', + async load() { + const { WebhookOutboxPlugin } = await import('@objectstack/plugin-webhooks'); + return { plugin: new WebhookOutboxPlugin({ autoEnqueue: false }) }; + }, + }, + { + source: '@objectstack/service-messaging', + async load() { + const { MessagingServicePlugin } = await import('@objectstack/service-messaging'); + return { plugin: new MessagingServicePlugin({}) }; + }, + }, + { + source: '@objectstack/service-datasource', + async load() { + const { DatasourceAdminServicePlugin } = await import('@objectstack/service-datasource'); + return { plugin: new DatasourceAdminServicePlugin({}) }; + }, + }, + { + source: '@objectstack/mcp (CONNECT_AGENT_UI_BUNDLE)', + async load() { + const { CONNECT_AGENT_UI_BUNDLE } = await import('@objectstack/mcp'); + return { manifests: [CONNECT_AGENT_UI_BUNDLE] }; + }, + }, + { + source: '@objectstack/cloud-connection (cloud + marketplace UI bundles)', + async load() { + const mod = await import('@objectstack/cloud-connection'); + return { + manifests: [ + mod.CLOUD_CONNECTION_UI_BUNDLE, + mod.MARKETPLACE_BROWSE_UI_BUNDLE, + mod.MARKETPLACE_INSTALLED_UI_BUNDLE, + ], + }; + }, + }, +]; + +// --------------------------------------------------------------------------- +// Self-test +// --------------------------------------------------------------------------- + +function selfTest() { + const failures = []; + const expect = (name, cond, detail) => { + if (!cond) failures.push(`${name} — ${detail}`); + }; + + // The merged shape, recorded from a real `registry.getApp('setup')`: groups + // carry their entries as `children`, so a walk that only reads the top level + // would find the nine group anchors and NONE of the entries — a gate that + // checks nothing and reports OK. + const MERGED_NAV_SAMPLE = [ + { id: 'group_overview', type: 'group', children: [{ id: 'nav_system_overview', type: 'dashboard', label: 'System Overview' }] }, + { + id: 'group_integrations', + type: 'group', + children: [ + { id: 'nav_webhooks', type: 'object', label: 'Webhooks' }, + { id: 'nav_http_deliveries', type: 'object', label: 'HTTP Deliveries' }, + ], + }, + ]; + expect( + '#5750 walks into group children', + collectNavIds(MERGED_NAV_SAMPLE).join(',') === + 'group_overview,nav_system_overview,group_integrations,nav_webhooks,nav_http_deliveries', + `got ${JSON.stringify(collectNavIds(MERGED_NAV_SAMPLE))}`, + ); + expect('#5750 an empty tree is no ids, never a crash', collectNavIds(undefined).length === 0, 'a missing navigation must not throw'); + + // The defect itself, in both spellings it arrived in. `nav_packages` HAD a + // translation — under `apps.studio.navigation`, an entirely different app — + // so the coverage question is per-app and cannot be answered by "does this + // string exist somewhere in the bundle". + const ids = collectNavIds(MERGED_NAV_SAMPLE); + const ZH_SETUP_NAV = { + group_overview: { label: '总览' }, + nav_system_overview: { label: '系统概览' }, + group_integrations: { label: '集成' }, + }; + expect( + '#5750 reports the untranslated ids', + missingLabels(ids, ZH_SETUP_NAV).join(',') === 'nav_webhooks,nav_http_deliveries', + `got ${JSON.stringify(missingLabels(ids, ZH_SETUP_NAV))}`, + ); + expect( + '#5750 a complete locale is empty', + missingLabels(ids, { ...ZH_SETUP_NAV, nav_webhooks: { label: 'Webhooks' }, nav_http_deliveries: { label: 'HTTP 投递' } }).length === 0, + 'a fully translated locale must not report a miss', + ); + // A key that exists with nothing usable in it is the same user-visible bug as + // a key that does not exist: the console renders the author's English literal. + expect( + '#5750 an empty label is missing', + missingLabels(['nav_webhooks'], { nav_webhooks: { label: ' ' } }).length === 1, + 'a blank label must not count as coverage', + ); + expect( + '#5750 a non-string label is missing', + missingLabels(['nav_webhooks'], { nav_webhooks: { label: 42 } }).length === 1, + 'a non-string label must not count as coverage', + ); + expect( + '#5750 a missing locale subtree is total, not zero', + missingLabels(ids, undefined).length === ids.length, + 'an absent apps.setup.navigation must report every id, never none', + ); + + // The direction this gate can fail SILENTLY. Fewer contributors means fewer + // ids means fewer checks, so a contributor that stops landing anything makes + // the run greener. It must be a failure with the contributor's name on it. + expect( + '#5750 names a contributor that landed nothing', + contributorsWithNoNavIds([ + { source: '@objectstack/plugin-webhooks', ids: [] }, + { source: '@objectstack/plugin-audit', ids: ['nav_audit_logs'] }, + ]).join(',') === '@objectstack/plugin-webhooks', + 'a silent contributor must be named, not tolerated', + ); + expect( + '#5750 a full composition raises nothing', + contributorsWithNoNavIds([{ source: 'x', ids: ['nav_a'] }]).length === 0, + 'every contributor landing ids is the green path', + ); + + // The rendered verdict must name the id, the package that declared it and the + // literal the console falls back to — a verdict a reader cannot act on is the + // same cost as no verdict. + const rendered = renderMissing( + 'zh-CN', + ['nav_http_deliveries'], + new Map([['nav_http_deliveries', { source: '@objectstack/plugin-webhooks', label: 'HTTP Deliveries' }]]), + ); + expect('#5750 verdict names the id', rendered.includes('nav_http_deliveries'), rendered); + expect('#5750 verdict names the contributor', rendered.includes('@objectstack/plugin-webhooks'), rendered); + expect('#5750 verdict names the fallback literal', rendered.includes('HTTP Deliveries'), rendered); + expect('#5750 verdict names the locale', rendered.includes('zh-CN'), rendered); + + if (failures.length) { + console.error(`\ncheck-app-nav-i18n --self-test: ${failures.length} failure(s)\n`); + for (const f of failures) console.error(` ${f}`); + process.exit(1); + } + console.log( + '✓ 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.', + ); +} + +if (process.argv.includes('--self-test')) { + selfTest(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// The prerequisite: this gate imports BUILT workspace packages. +// --------------------------------------------------------------------------- + +/** + * Answered once, before anything is imported — a missing build must cost one + * stated verdict, never a node stack pointing at whichever package happened to + * be imported first (the #5862 lesson on the neighbouring i18n gates). + */ +function checkBuildPrerequisite() { + const probe = join(CLI_ROOT, 'node_modules', '@objectstack', 'setup', 'dist', 'index.mjs'); + if (existsSync(probe)) return; + console.error( + `\ncheck-app-nav-i18n: PREREQUISITE NOT MET — the workspace packages are not built\n\n` + + ` This gate boots the real Setup composition, so it imports the BUILT output of\n` + + ` every contributing package. This one is not there:\n\n` + + ` ${probe}\n\n` + + ` Fix: pnpm build (or: pnpm --filter '@objectstack/cli^...' build)\n\n` + + ` Nothing was measured: no app was merged and no locale was compared, so this\n` + + ` result says NOTHING about whether any nav label went untranslated.\n` + + ` (Exit code 1 — but piping this gate reports the PIPE's status. Use \`echo "EXIT=$?"\`.)`, + ); + process.exit(1); +} + +checkBuildPrerequisite(); + +// --------------------------------------------------------------------------- +// Boot the composition and read the merged app back. +// --------------------------------------------------------------------------- + +const { ObjectQL } = await import('@objectstack/objectql'); +const { SetupAppTranslations } = await import('@objectstack/platform-objects/apps'); + +const engine = new ObjectQL(); +engine.registry.logLevel = 'silent'; + +/** Manifests registered by the contributor currently being booted. */ +let currentSink = []; + +/** + * A `PluginContext` that provides exactly one real service — `manifest`, whose + * `register` is what every nav contribution flows through — and inert, COMPLETE + * implementations of the rest of the interface (`packages/core/src/types.ts`). + * + * Complete on purpose: a stub missing a documented member makes a plugin die + * with `ctx. is not a function`, which this gate would then report as "could + * not boot" — a true statement about the harness dressed up as a finding about + * the plugin. Measured: `ctx.trigger` was the first one, from + * `service-datasource`. + */ +const ctx = { + getService: (name) => (name === 'manifest' ? { register: (m) => currentSink.push(m) } : undefined), + registerService: () => {}, + registerServiceFactory: () => {}, + replaceService: () => {}, + getServiceScoped: async () => undefined, + getServices: () => new Map(), + hook: () => {}, + trigger: async () => {}, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getKernel: () => undefined, +}; + +/** `{ source, ids }` per contributor, plus id → { source, label } for the verdict. */ +const contributions = []; +const declaredBy = new Map(); + +for (const contributor of CONTRIBUTORS) { + currentSink = []; + let loaded; + try { + loaded = await contributor.load(); + if (loaded.plugin) { + await loaded.plugin.init?.(ctx); + await loaded.plugin.start?.(ctx); + } + for (const manifest of loaded.manifests ?? []) currentSink.push(manifest); + } catch (err) { + // Never swallowed: a contributor that cannot boot contributes nothing, and + // a gate that continues past it silently measures a smaller app. + console.error( + `\ncheck-app-nav-i18n: COULD NOT BOOT — ${contributor.source}\n\n` + + ` ${String(err?.message ?? err)}\n\n` + + ` Nothing was compared. This gate's verdict depends on the composition being\n` + + ` complete, so a contributor that fails to boot ends the run rather than\n` + + ` shrinking the app it judges.`, + ); + process.exit(1); + } + + const ids = []; + for (const manifest of currentSink) { + for (const contribution of manifest?.navigationContributions ?? []) { + if (contribution?.app !== APP_NAME) continue; + for (const item of contribution.items ?? []) { + if (!item?.id) continue; + ids.push(item.id); + declaredBy.set(item.id, { source: contributor.source, label: item.label }); + } + } + // 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; + for (const id of collectNavIds(app.navigation)) { + ids.push(id); + if (!declaredBy.has(id)) declaredBy.set(id, { source: contributor.source, label: undefined }); + } + } + engine.registerApp(manifest); + } + contributions.push({ source: contributor.source, ids }); +} + +const mergedApp = engine.registry.getApp(APP_NAME); +const mergedIds = collectNavIds(mergedApp?.navigation); + +// The two verdicts are kept apart because their REMEDIES are opposites, and a +// footer that prescribes one for the other is the #5862 defect (a confident +// diagnosis pointing somewhere innocent) rebuilt in this gate. +const compositionErrors = []; +const coverageErrors = []; + +// 1. The composition is complete — checked BEFORE the coverage verdict, because +// an incomplete composition cannot give one. +for (const source of contributorsWithNoNavIds(contributions)) { + compositionErrors.push( + `${source} landed NO \`${APP_NAME}\` navigation id. Either it stopped contributing (then remove it ` + + `from CONTRIBUTORS in this script, in the same PR) or its registration silently no-ops — which would ` + + `make this gate greener, not redder, by giving it fewer ids to check.`, + ); +} +if (!mergedApp) { + compositionErrors.push( + `the \`${APP_NAME}\` app is not registered at all — the composition produced no app to judge.`, + ); +} + +// 2. Every merged id carries a label in every locale the bundle declares. +if (compositionErrors.length === 0) { + for (const [locale, data] of Object.entries(SetupAppTranslations)) { + const missing = missingLabels(mergedIds, data?.apps?.[APP_NAME]?.navigation); + if (missing.length) coverageErrors.push(renderMissing(locale, missing, declaredBy)); + } +} + +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'); + console.error( + compositionErrors.length + ? ` Nothing was compared: the coverage verdict is only meaningful over a COMPLETE\n` + + ` composition, so it was not attempted. This result says nothing about whether any\n` + + ` nav label went untranslated.` + : ` These ids exist only AFTER the runtime merge, so neither \`pnpm check:i18n\` nor\n` + + ` \`pnpm check:i18n-coverage\` can see them — that gap is what this gate closes (#5750).\n` + + ` Fix by adding the label to \`apps.${APP_NAME}.navigation\` in EVERY locale file under\n` + + ` packages/platform-objects/src/apps/translations/ (en, zh-CN, ja-JP, es-ES).`, + ); + process.exit(1); +} + +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).`, +); diff --git a/packages/platform-objects/scripts/i18n-extract.config.ts b/packages/platform-objects/scripts/i18n-extract.config.ts index 953ed36007..ca3d4b7a3f 100644 --- a/packages/platform-objects/scripts/i18n-extract.config.ts +++ b/packages/platform-objects/scripts/i18n-extract.config.ts @@ -36,9 +36,22 @@ * 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 the coverage ratchet - * (`scripts/check-i18n-coverage.mjs`), baselined at 0 for this package, - * not the bundle-drift gate. + * 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. + * + * This paragraph used to end "Their gate is the coverage ratchet + * (`scripts/check-i18n-coverage.mjs`), baselined at 0 for this package", + * and that sentence was half of the #5750 defect. The ratchet runs + * `os lint` over STATIC configs — the same static walk two lines up says + * is structurally incomplete for Setup — so it could never see a + * runtime-contributed label. Its 0 for this package meant "not looked at + * here", not "checked, clean", while `app-nav-translation-parity.test.ts` + * excluded Setup and pointed at the ratchet from the other side. Four + * `zh-CN` nav labels were missing under a fully green build. The ratchet + * 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. * * Omitting the hand-authored half was a measurable bug, not a style choice: * this config declares SETUP_APP / STUDIO_APP / ACCOUNT_APP and diff --git a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts index 68f6f64d33..5af3592a5c 100644 --- a/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts +++ b/packages/platform-objects/src/apps/translations/app-nav-translation-parity.test.ts @@ -22,7 +22,22 @@ // // Setup is deliberately NOT covered here: its nav ids do not exist on the app // object at all until the runtime merges contributions in, so this file would -// have nothing to walk. Those labels are gated by the coverage ratchet. +// have nothing to walk. +// +// Where they ARE covered: `pnpm check:app-nav-i18n` +// (`packages/cli/scripts/check-app-nav-i18n.mjs`), which boots the real +// composition, merges the contributions through `applyNavContributions`, and +// asserts the same invariant this file asserts — every nav id labelled in every +// locale — over the merged tree. +// +// This line used to read "Those labels are gated by the coverage ratchet", +// which was the #5750 defect in one sentence. The ratchet +// (`scripts/check-i18n-coverage.mjs`) runs `os lint` over STATIC stack configs +// and never saw a runtime-contributed id in its life; the extract config on the +// other side named the ratchet right back. Two comments declared an owner, no +// gate implemented one, and four Setup nav entries sat untranslated in `zh-CN` +// under a green build. Do not re-delegate Setup to a gate that cannot walk it — +// if this file grows a Setup case, it has to boot something. import { describe, it, expect } from 'vitest'; import { STUDIO_APP } from '../studio.app.js'; diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 214ed621c7..5daf354259 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -58,6 +58,12 @@ export const en: TranslationData = { nav_system_overview: { label: 'System Overview' }, // Apps / Marketplace + // `nav_packages` is a SETUP entry (package administration is an + // operator concern — ADR-0084), distinct from the same-id entry under + // `apps.studio.navigation`. Two apps carrying one nav id is normal; + // each app resolves its own subtree, so the studio copy never answers + // for this one. Its absence here was #5750's first cause. + nav_packages: { label: 'Packages' }, nav_marketplace_browse: { label: 'Browse Marketplace' }, nav_marketplace_installed: { label: 'Installed Apps' }, nav_cloud_connection: { label: 'Cloud Connection' }, @@ -72,6 +78,7 @@ export const en: TranslationData = { // Access Control nav_positions: { label: 'Positions' }, + nav_capabilities: { label: 'Capabilities' }, nav_permission_sets: { label: 'Permission Sets' }, nav_sharing_rules: { label: 'Sharing Rules' }, nav_record_shares: { label: 'Record Shares' }, @@ -82,9 +89,12 @@ export const en: TranslationData = { nav_approval_processes: { label: 'Processes' }, nav_approval_requests: { label: 'Requests' }, nav_approval_actions: { label: 'Action History' }, + nav_approval_delegations: { label: 'Delegations (OOO)' }, // Configuration nav_settings_hub: { label: 'All Settings' }, + nav_settings_localization: { label: 'Localization' }, + nav_settings_company: { label: 'Company' }, nav_settings_mail: { label: 'Email' }, nav_settings_branding: { label: 'Branding' }, nav_settings_auth: { label: 'Authentication' }, @@ -102,6 +112,14 @@ export const en: TranslationData = { nav_audit_logs: { label: 'Audit Logs' }, nav_notifications: { label: 'Notifications' }, + // Integrations — every entry here is contributed at RUNTIME by the + // capability plugin that owns the object (ADR-0029 K2), so it exists on + // no static walk of SETUP_APP. `pnpm check:app-nav-i18n` is what keeps + // this block honest; see that gate's header for why (#5750). + nav_webhooks: { label: 'Webhooks' }, + nav_http_deliveries: { label: 'HTTP Deliveries' }, + nav_datasources: { label: 'Datasources' }, + // Advanced nav_oauth_apps: { label: 'OAuth Applications' }, nav_jwks: { label: 'Signing Keys (JWKS)' }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 504b9b0163..102f0c75a5 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -32,6 +32,9 @@ export const esES: TranslationData = { navigation: { group_overview: { label: 'Resumen' }, group_apps: { label: 'Aplicaciones' }, + // Setup's own `nav_packages` — distinct from the same id under + // `apps.studio.navigation`, which does not answer for this app (#5750). + nav_packages: { label: 'Paquetes' }, nav_marketplace_browse: { label: 'Explorar Marketplace' }, nav_marketplace_installed: { label: 'Aplicaciones instaladas' }, nav_cloud_connection: { label: 'Conexión a la nube' }, @@ -53,6 +56,7 @@ export const esES: TranslationData = { nav_invitations: { label: 'Invitaciones' }, nav_positions: { label: 'Posiciones' }, + nav_capabilities: { label: 'Capacidades' }, nav_permission_sets: { label: 'Conjuntos de Permisos' }, nav_sharing_rules: { label: 'Reglas de Compartición' }, nav_record_shares: { label: 'Registros Compartidos' }, @@ -62,8 +66,11 @@ export const esES: TranslationData = { nav_approval_processes: { label: 'Procesos' }, nav_approval_requests: { label: 'Solicitudes' }, nav_approval_actions: { label: 'Historial de Acciones' }, + nav_approval_delegations: { label: 'Delegaciones (ausencia)' }, nav_settings_hub: { label: 'Todos los Ajustes' }, + nav_settings_localization: { label: 'Localización' }, + nav_settings_company: { label: 'Empresa' }, nav_settings_mail: { label: 'Correo' }, nav_settings_branding: { label: 'Marca' }, nav_settings_auth: { label: 'Autenticación' }, @@ -79,6 +86,12 @@ export const esES: TranslationData = { nav_audit_logs: { label: 'Registros de Auditoría' }, nav_notifications: { label: 'Notificaciones' }, + // Integrations — contributed at RUNTIME by the owning capability + // plugins (ADR-0029 K2); gated by `pnpm check:app-nav-i18n` (#5750). + nav_webhooks: { label: 'Webhooks' }, + nav_http_deliveries: { label: 'Entregas HTTP' }, + nav_datasources: { label: 'Fuentes de datos' }, + nav_oauth_apps: { label: 'Aplicaciones OAuth' }, nav_jwks: { label: 'Claves de Firma (JWKS)' }, nav_verifications: { label: 'Verificaciones' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 70c8162c54..33a414f069 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -32,6 +32,9 @@ export const jaJP: TranslationData = { navigation: { group_overview: { label: '概要' }, group_apps: { label: 'アプリ' }, + // Setup's own `nav_packages` — distinct from the same id under + // `apps.studio.navigation`, which does not answer for this app (#5750). + nav_packages: { label: 'パッケージ' }, nav_marketplace_browse: { label: 'マーケットプレイスを閲覧' }, nav_marketplace_installed: { label: 'インストール済みアプリ' }, nav_cloud_connection: { label: 'クラウド接続' }, @@ -53,6 +56,7 @@ export const jaJP: TranslationData = { nav_invitations: { label: '招待' }, nav_positions: { label: 'ポジション' }, + nav_capabilities: { label: 'ケイパビリティ' }, nav_permission_sets: { label: '権限セット' }, nav_sharing_rules: { label: '共有ルール' }, nav_record_shares: { label: 'レコード共有' }, @@ -62,8 +66,11 @@ export const jaJP: TranslationData = { nav_approval_processes: { label: 'プロセス' }, nav_approval_requests: { label: 'リクエスト' }, nav_approval_actions: { label: 'アクション履歴' }, + nav_approval_delegations: { label: '委任 (不在時)' }, nav_settings_hub: { label: 'すべての設定' }, + nav_settings_localization: { label: 'ローカリゼーション' }, + nav_settings_company: { label: '会社情報' }, nav_settings_mail: { label: 'メール' }, nav_settings_branding: { label: 'ブランディング' }, nav_settings_auth: { label: '認証' }, @@ -79,6 +86,12 @@ export const jaJP: TranslationData = { nav_audit_logs: { label: '監査ログ' }, nav_notifications: { label: '通知' }, + // Integrations — contributed at RUNTIME by the owning capability + // plugins (ADR-0029 K2); gated by `pnpm check:app-nav-i18n` (#5750). + nav_webhooks: { label: 'Webhooks' }, + nav_http_deliveries: { label: 'HTTP 配信' }, + nav_datasources: { label: 'データソース' }, + nav_oauth_apps: { label: 'OAuth アプリケーション' }, nav_jwks: { label: '署名キー (JWKS)' }, nav_verifications: { label: '検証' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index c9a87e5702..022a5b029d 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -32,6 +32,12 @@ export const zhCN: TranslationData = { navigation: { group_overview: { label: '总览' }, group_apps: { label: '应用' }, + // The Setup app's own `nav_packages`. A translation for this id already + // existed under `apps.studio.navigation` and did NOT answer here — the + // lookup is per app — so Setup fell back to the author's 'Packages' + // literal in an otherwise Chinese menu (#5750). Both entries are + // legitimate; keep them both. + nav_packages: { label: '软件包' }, nav_marketplace_browse: { label: '浏览应用市场' }, nav_marketplace_installed: { label: '已安装应用' }, nav_cloud_connection: { label: '云连接' }, @@ -63,6 +69,9 @@ export const zhCN: TranslationData = { nav_approval_processes: { label: '审批流程' }, nav_approval_requests: { label: '审批申请' }, nav_approval_actions: { label: '审批历史' }, + // `审批委派` matches sys_approval_delegation's object label; `(外出)` + // renders the entry's own "(OOO)" qualifier. + nav_approval_delegations: { label: '审批委派(外出)' }, nav_settings_hub: { label: '全部设置' }, nav_settings_localization: { label: '本地化' }, @@ -82,6 +91,13 @@ export const zhCN: TranslationData = { nav_audit_logs: { label: '审计日志' }, nav_notifications: { label: '通知' }, + // Integrations — contributed at RUNTIME by the owning capability + // plugins (ADR-0029 K2), so no static walk of SETUP_APP can see them. + // `Webhooks` stays the loanword deliberately: sys_webhook's own zh-CN + // object label is `Webhook` too, and this bundle is where that choice + // is now RECORDED rather than reached by falling back (#5750). + nav_webhooks: { label: 'Webhooks' }, + nav_http_deliveries: { label: 'HTTP 投递' }, nav_datasources: { label: '数据源' }, nav_oauth_apps: { label: 'OAuth 应用' }, diff --git a/scripts/check-i18n-coverage.mjs b/scripts/check-i18n-coverage.mjs index 8e369550b9..8c88cf0ea0 100644 --- a/scripts/check-i18n-coverage.mjs +++ b/scripts/check-i18n-coverage.mjs @@ -33,6 +33,21 @@ // build step with the other consumer gates. `--self-test` does not: it drives the // pure classifiers against recorded samples, no build and no CLI. // +// WHAT THIS GATE CANNOT SEE, stated because two other comments once assumed it +// could (#5750). It lints STATIC stack configs, so it measures exactly what a +// config DECLARES. Metadata assembled at RUNTIME is outside it by construction — +// most consequentially the Setup app's navigation, which is a shell of empty +// group anchors filled in by `SETUP_NAV_CONTRIBUTIONS` and by capability +// plugins (ADR-0029 D7). `platform-objects`' extract config and +// `app-nav-translation-parity.test.ts` each excluded those labels and named the +// other side as the owner; the ratchet's 0 for this package was "not looked at +// here", and four Setup nav ids sat untranslated in `zh-CN` under a green run of +// this very script. That half now has its own gate — `pnpm check:app-nav-i18n` +// (`packages/cli/scripts/check-app-nav-i18n.mjs`), which boots the composition +// and judges the merged app. Do not extend this script to cover it: the two ask +// different questions of different inputs, and folding a kernel boot into an +// `os lint` loop would make neither readable. +// // 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: