From 9772f9fca9fed8014a06cea0b9651eae3114867b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:18:35 +0000 Subject: [PATCH 1/5] fix(cli): expect a contributed navigation item's label under its target app collectExpectedEntries walked apps[].navigation only, so a key for an item another package contributes through manifest.navigationContributions was never expected and deleting its translation left os lint and os i18n check green, though translation-target-unknown accepts that key. Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- packages/cli/src/utils/i18n-extract.ts | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index b744d4c7bc4..c23f3ab341d 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -93,6 +93,9 @@ * globalActions..params..* / .resultDialog.* (same shape as object actions) * apps..label / .description * apps..navigation..label + * ^ an item another package CONTRIBUTES into the app through + * `manifest.navigationContributions` too, keyed under the TARGET app + * (#24993; see `navigationContributionsOf`) * dashboards..label / .description * dashboards..widgets..title / .description * datasets..label / .description @@ -1451,6 +1454,22 @@ export function collectExpectedEntries( walkNavigation(nav, appName, out); } + // ── Contributed navigation (ADR-0029 D7, #24993) ───────────────── + // An item a package injects into another package's app never enters that + // app's authored `navigation`: the runtime folds it in on every read + // (`SchemaRegistry.applyNavContributions`) and `translateApp` then resolves + // its label from `apps..navigation..label` — the key + // `translation-target-unknown` already accepts for a contributed item + // (#18203). Walked AFTER the apps' own trees and by the same + // `walkNavigation`, so a contributed group's children follow the rule an own + // group's do, and an id an app also declares keeps its own entry + // (`dedupeByPath`, first emission wins). + for (const contribution of navigationContributionsOf(config)) { + const targetApp = contribution?.app; + if (typeof targetApp !== 'string' || targetApp.length === 0) continue; + if (Array.isArray(contribution.items)) walkNavigation(contribution.items, targetApp, out); + } + // ── Dashboards + widgets ───────────────────────────────────────── const dashboards: any[] = Array.isArray(config?.dashboards) ? config.dashboards : []; for (const dash of dashboards) { @@ -2146,6 +2165,45 @@ function walkNavigation(nav: any[], appName: string, out: ExpectedEntry[]): void } } +/** + * Every `navigationContributions[]` entry the stack carries, from the two + * carriers `translation-target-unknown` reads (`contributedNavItemsByApp` in + * `@objectstack/lint`'s `validate-translation-references.ts`), so the keys + * coverage expects are the keys that rule accepts: + * + * 1. `manifest.navigationContributions` — where a single-`defineStack` + * project's contributions live; + * 2. `packages[].manifest.navigationContributions` — the ADR-0130 D4 artifact + * entry, whose `manifest` is the assembled package body. On a + * `composeStacks(…, { manifest: 'preserve' })` stack this is the only + * place a module's contributions sit: the singular top-level `manifest` + * is one package's copy (the `'last'` pick), usually the app's. + * + * Both are read unconditionally, as the rule reads them. Where the top-level + * manifest IS a copy of a contributing package's, the two carriers yield the + * same items and `dedupeByPath` keeps one entry per key. + * + * ⛔ The rule's reader is not imported: it is module-private to + * `@objectstack/lint`. This copy is held to it by + * `test/i18n-contributed-nav-coverage.test.ts`, which runs the rule over a + * bundle carrying every contributed key this walk expects. + * + * Not seen, here as there: a contribution registered imperatively from a + * plugin's `init` (`engine.registerAppNavContribution`) is not metadata; + * `packages/cli/scripts/check-app-nav-i18n.mjs` boots a composition to judge + * that population. + */ +function navigationContributionsOf(config: any): any[] { + const out: any[] = []; + const add = (list: unknown): void => { + if (Array.isArray(list)) out.push(...list); + }; + add(config?.manifest?.navigationContributions); + const packages: unknown[] = Array.isArray(config?.packages) ? config.packages : []; + for (const entry of packages) add((entry as any)?.manifest?.navigationContributions); + return out; +} + // ─── Filter + bundle assembly ────────────────────────────────────────── function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean { From 77f275a398581b22679aa67dab16438397ce9b61 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:20:02 +0000 Subject: [PATCH 2/5] test(cli): pin contributed navigation coverage under the target app Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../i18n-contributed-nav-coverage.test.ts | 229 ++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 packages/cli/test/i18n-contributed-nav-coverage.test.ts diff --git a/packages/cli/test/i18n-contributed-nav-coverage.test.ts b/packages/cli/test/i18n-contributed-nav-coverage.test.ts new file mode 100644 index 00000000000..13fa95c8cd0 --- /dev/null +++ b/packages/cli/test/i18n-contributed-nav-coverage.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#24993 — a navigation item a package CONTRIBUTES into another +// package's app (`manifest.navigationContributions`, ADR-0029 D7) owes its +// translation under the TARGET app, like an item the app declares itself. +// +// The runtime folds a contributed item into the target app's navigation and +// `translateApp` resolves its label from `apps..navigation..label`; +// `translation-target-unknown` accepts that key (#18203). The walker read +// `apps[].navigation` only, so the key was never expected: deleting a +// contributed item's zh-CN label left `os lint --strict` and `os i18n check` +// green, measured on HotCRM's `nav_case`, while the same deletion on one of +// the app's own items reported `i18n/missing-navigation`. +// +// The fixture is the card's shape: a service module contributing into the +// app's group and adding a group of its own, composed module-first with +// `manifest: 'preserve'`, so the contributions live only in the module's +// `packages[]` body. Both doors hand `computeI18nCoverage` the +// `authoringRuleUnionStack` of that composition, which is what these pins do. + +import { describe, it, expect } from 'vitest'; +import { composeStacks, defineStack } from '@objectstack/spec'; +import { TRANSLATION_TARGET_UNKNOWN, validateTranslationReferences } from '@objectstack/lint'; + +import { collectExpectedEntries } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { authoringRuleUnionStack } from '../src/utils/stack-collections.js'; + +const I18N = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' }; +const engines = { protocol: '^18' }; + +const appManifest = { + id: 'com.example.cnav.app', name: 'Contributed Nav App', namespace: 'cnv', version: '1.0.0', type: 'app', engines, +}; + +/** Into the app's own group, and a group of the module's own with a child. */ +const CONTRIBUTIONS = [ + { + app: 'cnv_desk', + group: 'grp_service', + items: [{ id: 'nav_case', type: 'object', objectName: 'cnv_case', label: 'Cases' }], + }, + { + app: 'cnv_desk', + items: [{ + id: 'grp_knowledge', + type: 'group', + label: 'Knowledge', + children: [{ id: 'nav_article', type: 'object', objectName: 'cnv_article', label: 'Articles' }], + }], + }, +]; + +const svcManifest = { + id: 'com.example.cnav.svc', name: 'Contributed Nav Service', namespace: 'cnv', version: '1.0.0', type: 'module', engines, + navigationContributions: CONTRIBUTIONS, +}; + +const object = (name: string, label: string) => ({ + name, label, pluralLabel: label, sharingModel: 'private', + fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } }, +}); + +const app = { + name: 'cnv_desk', + label: 'Desk', + navigation: [{ + id: 'grp_service', + type: 'group', + label: 'Service', + children: [{ id: 'nav_accounts', type: 'object', objectName: 'cnv_account', label: 'Accounts' }], + }], +}; + +/** The zh-CN navigation labels, every one present; a case deletes one. */ +const OWN_NAV_ZH = { grp_service: { label: '服务' }, nav_accounts: { label: '客户' } }; +const CONTRIBUTED_NAV_ZH = { nav_case: { label: '工单' }, grp_knowledge: { label: '知识' }, nav_article: { label: '文章' } }; + +const without = (labels: Record, id?: string) => + Object.fromEntries(Object.entries(labels).filter(([key]) => key !== id)); + +/** The app package carries the app's own labels; the module, the labels of what it contributes. */ +function stacks(drop?: string) { + const appStack = defineStack({ + manifest: appManifest, + objects: [object('cnv_account', 'Account')], + apps: [app], + i18n: I18N, + translations: [{ 'zh-CN': { apps: { cnv_desk: { label: '服务台', navigation: without(OWN_NAV_ZH, drop) } } } }], + } as any); + const svcStack = defineStack({ + manifest: svcManifest, + objects: [object('cnv_case', 'Case'), object('cnv_article', 'Article')], + i18n: I18N, + translations: [{ 'zh-CN': { apps: { cnv_desk: { navigation: without(CONTRIBUTED_NAV_ZH, drop) } } } }], + } as any); + return { appStack, svcStack }; +} + +/** Module first, as HotCRM composes it: the top-level manifest is the app's. */ +function composed(drop?: string): Record { + const { appStack, svcStack } = stacks(drop); + return authoringRuleUnionStack(composeStacks([svcStack, appStack], { manifest: 'preserve' }) as any); +} + +const navEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'apps' && e.path[2] === 'navigation'); + +const zhNavIssueKeys = (config: any) => + computeI18nCoverage(config, { strict: true }).issues + .filter((i) => i.locale === 'zh-CN' && i.source === 'navigation') + .map((i) => i.key); + +describe('what the walker expects from a contributed navigation item (#24993)', () => { + it('reads the contributions from the module\'s package body alone — the fixture is the card\'s shape', () => { + const stack = composed() as any; + expect(stack.manifest.navigationContributions).toBeUndefined(); + expect(stack.packages.map((p: any) => p.manifest.navigationContributions?.length ?? 0)).toEqual([2, 0]); + }); + + it('expects each contributed item under the TARGET app, seeded with its own label, after the app\'s own items', () => { + const entries = navEntries(composed()); + expect(entries.map((e) => e.path.join('.'))).toEqual([ + 'apps.cnv_desk.navigation.grp_service.label', + 'apps.cnv_desk.navigation.nav_accounts.label', + 'apps.cnv_desk.navigation.nav_case.label', + 'apps.cnv_desk.navigation.grp_knowledge.label', + 'apps.cnv_desk.navigation.nav_article.label', + ]); + expect(entries[2]).toMatchObject({ sourceValue: 'Cases', inline: 'Cases', source: 'navigation', appName: 'cnv_desk' }); + expect(entries[4]).toMatchObject({ sourceValue: 'Articles', inline: 'Articles', source: 'navigation', appName: 'cnv_desk' }); + }); + + it('reads a single stack\'s own manifest too, keyed under an app the stack only contributes into', () => { + const single = defineStack({ + manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] }, + objects: [object('cnv_case', 'Case')], + } as any); + expect(navEntries(single).map((e) => e.path.join('.'))).toEqual(['apps.crm_enterprise.navigation.nav_case.label']); + }); + + it('expects each key once when the top-level manifest is the contributor\'s copy', () => { + const { appStack, svcStack } = stacks(); + const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any) as any; + // Both carriers hold the module's contributions here. + expect(appLast.manifest.navigationContributions).toHaveLength(2); + expect(navEntries(appLast).map((e) => e.path.join('.'))).toEqual([ + 'apps.cnv_desk.navigation.grp_service.label', + 'apps.cnv_desk.navigation.nav_accounts.label', + 'apps.cnv_desk.navigation.nav_case.label', + 'apps.cnv_desk.navigation.grp_knowledge.label', + 'apps.cnv_desk.navigation.nav_article.label', + ]); + }); + + it('keeps the app\'s own entry when a contribution reuses one of its ids', () => { + const config = { + apps: [app], + manifest: { navigationContributions: [{ app: 'cnv_desk', items: [{ id: 'nav_accounts', type: 'url', url: '/x', label: 'Elsewhere' }] }] }, + }; + const entries = navEntries(config); + expect(entries.map((e) => e.path.join('.'))).toEqual([ + 'apps.cnv_desk.navigation.grp_service.label', + 'apps.cnv_desk.navigation.nav_accounts.label', + ]); + expect(entries[1].sourceValue).toBe('Accounts'); + }); +}); + +describe('the coverage gate reports a missing contributed item as it reports an own one (#24993)', () => { + it('reports no navigation gap while every label is translated', () => { + expect(zhNavIssueKeys(composed())).toEqual([]); + }); + + it('reports a deleted contributed item', () => { + expect(zhNavIssueKeys(composed('nav_case'))).toEqual(['apps.cnv_desk.navigation.nav_case.label']); + }); + + it('reports a deleted child of a contributed group', () => { + expect(zhNavIssueKeys(composed('nav_article'))).toEqual(['apps.cnv_desk.navigation.nav_article.label']); + }); + + it('control: reports a deleted item the app declares itself, as before', () => { + expect(zhNavIssueKeys(composed('nav_accounts'))).toEqual(['apps.cnv_desk.navigation.nav_accounts.label']); + }); + + it('reports the deleted key once when both carriers hold the contribution', () => { + const { appStack, svcStack } = stacks('nav_case'); + const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any); + expect(zhNavIssueKeys(appLast)).toEqual(['apps.cnv_desk.navigation.nav_case.label']); + }); +}); + +describe('every contributed key the walker expects is one translation-target-unknown accepts (#24993)', () => { + /** A zh-CN bundle carrying exactly the navigation keys the walker expects. */ + const bundleOf = (config: any) => { + const navigation: Record = {}; + const apps: Record = {}; + for (const entry of navEntries(config)) { + const [, appName, , id] = entry.path; + apps[appName] ??= { navigation: {} }; + apps[appName].navigation[id] = { label: 'x' }; + } + return { 'zh-CN': { apps } }; + }; + const unknownTargets = (stack: any, bundle: unknown) => + validateTranslationReferences({ ...stack, translations: [bundle] }) + .filter((f) => f.rule === TRANSLATION_TARGET_UNKNOWN) + .map((f) => f.path); + + it('accepts every expected key on the composed stack', () => { + const stack = composed(); + expect(unknownTargets(stack, bundleOf(stack))).toEqual([]); + }); + + it('accepts the expected key under an app the stack only contributes into', () => { + const single = defineStack({ + manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] }, + objects: [object('cnv_case', 'Case')], + } as any) as any; + expect(unknownTargets(single, bundleOf(single))).toEqual([]); + }); + + it('control: still refuses a navigation id nothing declares or contributes', () => { + const stack = composed(); + const bundle = { 'zh-CN': { apps: { cnv_desk: { navigation: { nav_ghost: { label: 'x' } } } } } }; + expect(unknownTargets(stack, bundle)).toEqual(['translations[0]["zh-CN"].apps.cnv_desk.navigation.nav_ghost']); + }); +}); From a2557ce65d22dbb3163cc4b702c209ff21cd1354 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:20:31 +0000 Subject: [PATCH 3/5] chore(changeset): cli patch for contributed navigation coverage Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../24993-contributed-nav-i18n-coverage.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .changeset/24993-contributed-nav-i18n-coverage.md diff --git a/.changeset/24993-contributed-nav-i18n-coverage.md b/.changeset/24993-contributed-nav-i18n-coverage.md new file mode 100644 index 00000000000..6ecbbd6d744 --- /dev/null +++ b/.changeset/24993-contributed-nav-i18n-coverage.md @@ -0,0 +1,24 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): `os lint` and `os i18n check` ask for the label of a navigation item another package contributes into an app (#24993) + +Clause-②: no +Metadata-protocol: none + +A package can add items to an app it does not own through `manifest.navigationContributions`. The runtime merges those items into the app's navigation and shows each one's label from `apps.APP.navigation.ID.label`, where `APP` is the app the item is contributed to. `os validate` already accepted that key. But the i18n extractor only read each app's own `navigation`, so a contributed item's key was never expected: deleting its translation left `os lint --strict` and `os i18n check` green. The same deletion on an item the app declares itself was reported. + +Now a contributed item is expected under its target app, from either place a contribution is declared: the stack's own `manifest`, or a package's manifest inside a `composeStacks(…, { manifest: 'preserve' })` artifact. Its source text is its own `label`. A contributed group's children are expected as an app's own group children are. An item the app also declares keeps the app's entry, and a contribution read from two places is expected once. + +**What you may see.** A project that contributes navigation and declares a locale beyond its default now gets an `i18n/missing-navigation` finding for each contributed item that locale's pack does not translate. It is a warning by default, which `os lint --strict` fails on. Under `os lint --i18n-strict` or `os i18n check --strict` it is an error, like every other missing translation. To clear them, translate the key under the target app in that locale's pack, for example: + +```ts +apps: { + crm_enterprise: { + navigation: { nav_case: { label: '工单' } }, + }, +}, +``` + +`os i18n extract` now writes these slots too, and keeps the strings a pack already translates. No key, schema or export changes: the key was already part of the translation contract. From d4e0e405a3c3fdb20c1369ad2e34c41b7a5265b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:24:54 +0000 Subject: [PATCH 4/5] test(cli): keep contributed nav labels in the app's pack, which carries the required app label Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../cli/test/i18n-contributed-nav-coverage.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/cli/test/i18n-contributed-nav-coverage.test.ts b/packages/cli/test/i18n-contributed-nav-coverage.test.ts index 13fa95c8cd0..a489f48d2ab 100644 --- a/packages/cli/test/i18n-contributed-nav-coverage.test.ts +++ b/packages/cli/test/i18n-contributed-nav-coverage.test.ts @@ -79,20 +79,26 @@ const CONTRIBUTED_NAV_ZH = { nav_case: { label: '工单' }, grp_knowledge: { lab const without = (labels: Record, id?: string) => Object.fromEntries(Object.entries(labels).filter(([key]) => key !== id)); -/** The app package carries the app's own labels; the module, the labels of what it contributes. */ +/** + * The app package's pack carries every navigation label, the contributed ones + * included — the layout HotCRM's packs were measured in (#18203). A pack's + * `apps.APP` entry requires the app's own `label`, so a module could ship the + * contributed labels itself only by restating it. + */ function stacks(drop?: string) { const appStack = defineStack({ manifest: appManifest, objects: [object('cnv_account', 'Account')], apps: [app], i18n: I18N, - translations: [{ 'zh-CN': { apps: { cnv_desk: { label: '服务台', navigation: without(OWN_NAV_ZH, drop) } } } }], + translations: [{ + 'zh-CN': { apps: { cnv_desk: { label: '服务台', navigation: without({ ...OWN_NAV_ZH, ...CONTRIBUTED_NAV_ZH }, drop) } } }, + }], } as any); const svcStack = defineStack({ manifest: svcManifest, objects: [object('cnv_case', 'Case'), object('cnv_article', 'Article')], i18n: I18N, - translations: [{ 'zh-CN': { apps: { cnv_desk: { navigation: without(CONTRIBUTED_NAV_ZH, drop) } } } }], } as any); return { appStack, svcStack }; } From 0c16d82462feeb2c39e87687586851cf26c61397 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 03:28:33 +0000 Subject: [PATCH 5/5] test(cli): make the target-rule parity pins non-vacuous Claude-Session: https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3 Co-authored-by: Claude --- .../cli/test/i18n-contributed-nav-coverage.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/i18n-contributed-nav-coverage.test.ts b/packages/cli/test/i18n-contributed-nav-coverage.test.ts index a489f48d2ab..b720fc2b287 100644 --- a/packages/cli/test/i18n-contributed-nav-coverage.test.ts +++ b/packages/cli/test/i18n-contributed-nav-coverage.test.ts @@ -216,7 +216,12 @@ describe('every contributed key the walker expects is one translation-target-unk it('accepts every expected key on the composed stack', () => { const stack = composed(); - expect(unknownTargets(stack, bundleOf(stack))).toEqual([]); + const bundle = bundleOf(stack); + // Not vacuous: the bundle carries the contributed ids, not only the app's own. + expect(Object.keys(bundle['zh-CN'].apps.cnv_desk.navigation)).toEqual([ + 'grp_service', 'nav_accounts', 'nav_case', 'grp_knowledge', 'nav_article', + ]); + expect(unknownTargets(stack, bundle)).toEqual([]); }); it('accepts the expected key under an app the stack only contributes into', () => { @@ -224,7 +229,9 @@ describe('every contributed key the walker expects is one translation-target-unk manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] }, objects: [object('cnv_case', 'Case')], } as any) as any; - expect(unknownTargets(single, bundleOf(single))).toEqual([]); + const bundle = bundleOf(single); + expect(Object.keys(bundle['zh-CN'].apps)).toEqual(['crm_enterprise']); + expect(unknownTargets(single, bundle)).toEqual([]); }); it('control: still refuses a navigation id nothing declares or contributes', () => {