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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/24993-contributed-nav-i18n-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions packages/cli/src/utils/i18n-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
* apps.<app>.label / .description
* apps.<app>.navigation.<id>.label
* ^ an item another package CONTRIBUTES into the app through
* `manifest.navigationContributions` too, keyed under the TARGET app
* (#24993; see `navigationContributionsOf`)
* dashboards.<dash>.label / .description
* dashboards.<dash>.widgets.<w>.title / .description
* datasets.<dataset>.label / .description
Expand Down Expand Up @@ -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.<target>.navigation.<id>.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) {
Expand Down Expand Up @@ -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 {
Expand Down
242 changes: 242 additions & 0 deletions packages/cli/test/i18n-contributed-nav-coverage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
// 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.<target>.navigation.<id>.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<string, unknown>, id?: string) =>
Object.fromEntries(Object.entries(labels).filter(([key]) => key !== id));

/**
* 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, ...CONTRIBUTED_NAV_ZH }, drop) } } },
}],
} as any);
const svcStack = defineStack({
manifest: svcManifest,
objects: [object('cnv_case', 'Case'), object('cnv_article', 'Article')],
i18n: I18N,
} as any);
return { appStack, svcStack };
}

/** Module first, as HotCRM composes it: the top-level manifest is the app's. */
function composed(drop?: string): Record<string, unknown> {
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<string, { label: string }> = {};
const apps: Record<string, { navigation: typeof navigation }> = {};
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();
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', () => {
const single = defineStack({
manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] },
objects: [object('cnv_case', 'Case')],
} as any) as any;
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', () => {
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']);
});
});
Loading