Skip to content

Commit 68a57ad

Browse files
fix(cli): os lint and os i18n check expect a contributed navigation item's label under its target app (#25171)
Fixes #24993 Clause-②: no Metadata-protocol: none ## What changes `collectExpectedEntries` (`packages/cli/src/utils/i18n-extract.ts`) is the one walk behind `os lint`'s `i18n/missing-*` family, `os i18n check` and `os i18n extract`. Its navigation arm read `apps[].navigation` only. An item a package contributes into an app through `manifest.navigationContributions` (ADR-0029 D7) never enters that array: the runtime folds it in on every read (`SchemaRegistry.applyNavContributions`), and `translateApp` resolves its label from `apps.TARGET.navigation.ID.label`. `translation-target-unknown` accepts that key since #18203. So the key was accepted but never expected, and deleting its translation left both doors green. - **New arm.** After the apps' own trees, every `navigationContributions[]` entry is walked by the same `walkNavigation`, keyed under its target `app`. The seed is the item's own `label`. A contributed group's children follow the rule an own group's children follow. An entry with no string `app` is skipped. - **Carriers.** `navigationContributionsOf` reads the two carriers the target rule reads (`contributedNavItemsByApp` in `packages/lint/src/validate-translation-references.ts`): the stack's own `manifest.navigationContributions`, and `packages[].manifest.navigationContributions` (the ADR-0130 D4 entry body). Both are read unconditionally, as the rule reads them. On a `preserve` composition whose last input is the contributor, both carriers hold the same items, and the walker's existing `dedupeByPath` keeps one entry per key (first emission wins, so an id an app also declares keeps the app's own entry). - **Unchanged.** An app's own items: same walk, same order, emitted first. `i18n-coverage.ts` is not touched: its surface phrase already reads "navigation items". - Changeset `.changeset/24993-contributed-nav-i18n-coverage.md`: `@objectstack/cli` `patch`. ## The mechanism hypotheses, measured (BASE `6b44efa7b`) - **H1: confirmed in shape; reuse not taken.** The arm adds each `navigationContributions[].items[]` id under its target app's `navigation` keys. The target rule's resolution is `contributedNavItemsByApp`, which is module-private to `@objectstack/lint` (its index exports `validateTranslationReferences`, not the reader). Importing it would need a new public export from `@objectstack/lint`, outside this card's file surface. The other in-repo readers each need a new import line in `i18n-extract.ts`'s import block, which the dispatch reserved for PR #24947: `collectNavGroupInputs` (cli) reads either/or carriers, not both; `stackPackageBodies` (`@objectstack/core`) covers only the package carrier. So the carrier read is written here, in about ten lines that mirror the rule's two carriers. It is held to the rule by a parity pin, described below. - **H2: confirmed.** The seed label is the contributed item's own `label` (`sourceValue` / `inline` = `Cases`). The key sits under the app it is contributed TO, read from every package body of the composed artifact (the union stack both doors hand the walker since #22238). - **H3: confirmed.** A contributed group (`grp_knowledge`) and its child (`nav_article`) are both expected, by the same `walkNavigation` the own tree uses. Deleting the child's key is reported. - **H4: refuted as a coverage move.** `examples/app-multi-package` contributes `nav_orders` into `multi_crm`, but it declares no `i18n` block and ships no bundle. Only the default locale is active, and its inline labels satisfy it. Its `check:i18n-coverage` count stays at its baselined `0`. No example declares a translated locale and also contributes navigation, so there was nothing to translate and no baseline moved. ## Both doors, measured on the card's probe The fixture has the card's shape: a service module contributes `nav_case` into the app's `grp_service` group, and adds a group of its own (`grp_knowledge` with child `nav_article`). It is composed module-first with `manifest: 'preserve'`, so the contributions live only in the module's package body. The fixture declares `en` and `zh-CN`, and every other key is translated. Each row deletes one zh-CN navigation key. Run with the source CLI (`bin/run-dev.js`) from a scratch directory, at `d4e0e405a`. The ablated runs used the same tree with the arm's loop emptied, as in the ablation below. | zh-CN key deleted | `os lint --strict --json`, fixed | ablated (arm removed) | `os i18n check --json --strict`, fixed | ablated | |---|---|---|---|---| | none | exit 0, `failing: 0` | exit 0 | exit 0, 15 keys expected | exit 0, 12 keys expected | | `nav_case` (contributed) | **exit 1**, `i18n/missing-navigation` `translations.zh-CN.apps.cnv_desk.navigation.nav_case.label` | exit 0, no finding | **exit 1**, `error zh-CN apps.cnv_desk.navigation.nav_case.label` | exit 0, no finding | | `nav_article` (child of a contributed group) | **exit 1**, same rule, `…nav_article.label` | exit 0, no finding | **exit 1**, `…nav_article.label` | exit 0, no finding | | `nav_accounts` (control: the app's own) | exit 1, `…nav_accounts.label` | exit 1, `…nav_accounts.label` | exit 1 | exit 1 | The ablated column reproduces the card's finding: the contributed deletions exit 0, and the own-item control exits 1. ## Tests New `packages/cli/test/i18n-contributed-nav-coverage.test.ts` (unit tier: no spawn, no kernel). It has 13 cases over the real `defineStack` + `composeStacks(…, { manifest: 'preserve' })` + `authoringRuleUnionStack` path that both doors take: - **The walker.** It expects each contributed item under the target app, after the app's own items, seeded with its own label. It reads a single stack's own manifest, including for an app the stack only contributes into. When the top-level manifest is the contributor's copy, each key is expected once. An own id reused by a contribution keeps the app's entry. - **The gate.** With every label translated, there is no navigation gap. A deleted contributed item is reported, and so is a deleted child of a contributed group. The own-item control is reported as before. When both carriers hold the contribution, the deleted key is reported once. - **Parity with `translation-target-unknown`.** A zh-CN bundle carrying exactly the navigation keys the walker expects draws no `translation-target-unknown` finding, on the composed stack and on a contribute-only single stack. Each case first asserts that the bundle really carries the contributed ids, so the pin cannot pass empty. The control: an id nothing declares or contributes is still refused. **Ablation.** The fix was committed first. `node scripts/ablation-replace.mjs` replaced the arm's `for (const contribution of navigationContributionsOf(config)) {` with a loop over `[]`. The tool reported the anchor going 1 → 0 and the blob `c23f3ab341d6` → `055daea8a714`. The test file and the subject resolve from `src/` (relative import; `run-dev.js` runs `src/` through tsx), so no `dist/` leg applies. - Ablated: **8 failed | 5 passed (13)**. All 8 contributed-specific cases went red. The 5 that stayed green are the controls: the fixture shape, the own-id dedupe, no gap when every label is translated, the own-item report, and the ghost-id refusal. - Restored: blob == HEAD (`c23f3ab341d6`), and `git diff HEAD` is empty (the tool's own verdict). - Fixed: **13 passed (13)**. ## Local verification Final head `4f263166d`: this branch plus a merge of `main` at `06900c9c2`, which brought one objectui pin bump and touches no file of this diff. Every lock run below printed its own VERDICT line, which is quoted. - **Build.** `pnpm --workspace-concurrency=2 --filter '@objectstack/cli^...' build` → `VERDICT command-exit 0`. - **Typecheck.** `pnpm --filter @objectstack/cli typecheck` (`tsc --noEmit` plus `check:test-typecheck`) → `VERDICT command-exit 0`, test-layer debt held at 3 files / 28 errors / 6 pinned signatures. `tsc -p tsconfig.test.json --listFiles` lists the new test file once, so it is type-checked. - **Unit tier.** `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` at `0c16d8246` (before the merge) → **Test Files 323 passed (323) · Tests 4769 passed (4769)**, `VERDICT command-exit 0`. The integration tier is left to CI: this diff touches no spawn entry and no integration-tier file. - **Re-check after the merge, at `4f263166d`.** The new file, plus the three cli tests that read `.objectui-sha`: **4 files, 44 tests passed**. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 68 commands. All 68 were run at `4f263166d`, with each exit code captured before any pipe. `--ran`: `68 derived famil(ies) accounted for — 66 run, 2 NOT-MEASURED`, 0 unrun. Readings that bear on this change: - `check:i18n-coverage`: `OK (14 config(s), 621 baselined untranslated string(s), none new)`. The first attempt refused with exit 3, `PREREQUISITE NOT MET` (the showcase example's `connector-slack` was unbuilt). The re-run came after the gate's own printed turbo build. - `check:i18n`: `OK (10 package(s) — all bundles in sync`. - `check:i18n-walk-parity`: `11 declared group(s), 9 walked, 2 exempted`. - `check:test-source-alias`, `check:cross-package-test-inputs`, `check:nul-bytes`, `check:issue-citations` and `check:doc-authoring`: green. - **NOT MEASURED: `check:type-check-debt`.** Reason: twice `VERDICT queue-timeout (exit 99)`. The shared verify lock was held more than 19 minutes by another session's `pnpm --filter @objectstack/dogfood test` (pid 28810, alive). CI's required jobs run it. - **NOT MEASURED: `check:dual-build-cjs-loads`.** Reason: `PREREQUISITE NOT MET`, because six packages outside this diff's closure have no `dist/` (`studio`, `client-react`, `embedder-openai`, `knowledge-ragflow`, `organizations`, `service-cluster-redis`). The build that would supply them got the same queue-timeout. CI's required jobs run it. - **Dogfood.** Not run: the title carries no `!` and `Clause-②` has no `(narrowing)` arm. ## Acceptance notes - **A module cannot ship a contributed item's translation without restating the target app's label.** In `TranslationDataSchema`, the `apps.APP` entry requires `label` (`packages/spec/src/system/translation.zod.ts`). Measured: a module pack carrying only `apps.cnv_desk.navigation.nav_case.label` is refused by `defineStack` with `translations.0.zh-CN.apps.cnv_desk.label: Required property 'label' is missing.` That refusal is loud, not silent. The way around it is to restate the label of an app the module does not own, and that restated label merges with the owner's translation. Whether the module's copy then wins at runtime is not measured. This is a boundary note, not filed. The fixture keeps every navigation label in the app package's pack, the layout HotCRM's packs were measured in (#18203). Carrier: none. - **The walker and the target rule still walk an item differently.** The walker reads `id ?? name` and descends `items` as well as `children`. The rule reads `id` and descends `children` only, plus `areas[].navigation`, which `translateApp` does not translate. For own items this predates this PR and is unchanged. On spec-valid input (an `id` and `children`) the two agree, and the parity pin holds them there. Noted, not filed. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01AFrKwndmrCLtF5V9hNtrw3)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab488d9 commit 68a57ad

3 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `os lint` and `os i18n check` ask for the label of a navigation item another package contributes into an app (#24993)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
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.
13+
14+
**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:
15+
16+
```ts
17+
apps: {
18+
crm_enterprise: {
19+
navigation: { nav_case: { label: '工单' } },
20+
},
21+
},
22+
```
23+
24+
`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.

‎packages/cli/src/utils/i18n-extract.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393
* globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions)
9494
* apps.<app>.label / .description
9595
* apps.<app>.navigation.<id>.label
96+
* ^ an item another package CONTRIBUTES into the app through
97+
* `manifest.navigationContributions` too, keyed under the TARGET app
98+
* (#24993; see `navigationContributionsOf`)
9699
* dashboards.<dash>.label / .description
97100
* dashboards.<dash>.widgets.<w>.title / .description
98101
* datasets.<dataset>.label / .description
@@ -1451,6 +1454,22 @@ export function collectExpectedEntries(
14511454
walkNavigation(nav, appName, out);
14521455
}
14531456

1457+
// ── Contributed navigation (ADR-0029 D7, #24993) ─────────────────
1458+
// An item a package injects into another package's app never enters that
1459+
// app's authored `navigation`: the runtime folds it in on every read
1460+
// (`SchemaRegistry.applyNavContributions`) and `translateApp` then resolves
1461+
// its label from `apps.<target>.navigation.<id>.label` — the key
1462+
// `translation-target-unknown` already accepts for a contributed item
1463+
// (#18203). Walked AFTER the apps' own trees and by the same
1464+
// `walkNavigation`, so a contributed group's children follow the rule an own
1465+
// group's do, and an id an app also declares keeps its own entry
1466+
// (`dedupeByPath`, first emission wins).
1467+
for (const contribution of navigationContributionsOf(config)) {
1468+
const targetApp = contribution?.app;
1469+
if (typeof targetApp !== 'string' || targetApp.length === 0) continue;
1470+
if (Array.isArray(contribution.items)) walkNavigation(contribution.items, targetApp, out);
1471+
}
1472+
14541473
// ── Dashboards + widgets ─────────────────────────────────────────
14551474
const dashboards: any[] = Array.isArray(config?.dashboards) ? config.dashboards : [];
14561475
for (const dash of dashboards) {
@@ -2146,6 +2165,45 @@ function walkNavigation(nav: any[], appName: string, out: ExpectedEntry[]): void
21462165
}
21472166
}
21482167

2168+
/**
2169+
* Every `navigationContributions[]` entry the stack carries, from the two
2170+
* carriers `translation-target-unknown` reads (`contributedNavItemsByApp` in
2171+
* `@objectstack/lint`'s `validate-translation-references.ts`), so the keys
2172+
* coverage expects are the keys that rule accepts:
2173+
*
2174+
* 1. `manifest.navigationContributions` — where a single-`defineStack`
2175+
* project's contributions live;
2176+
* 2. `packages[].manifest.navigationContributions` — the ADR-0130 D4 artifact
2177+
* entry, whose `manifest` is the assembled package body. On a
2178+
* `composeStacks(…, { manifest: 'preserve' })` stack this is the only
2179+
* place a module's contributions sit: the singular top-level `manifest`
2180+
* is one package's copy (the `'last'` pick), usually the app's.
2181+
*
2182+
* Both are read unconditionally, as the rule reads them. Where the top-level
2183+
* manifest IS a copy of a contributing package's, the two carriers yield the
2184+
* same items and `dedupeByPath` keeps one entry per key.
2185+
*
2186+
* ⛔ The rule's reader is not imported: it is module-private to
2187+
* `@objectstack/lint`. This copy is held to it by
2188+
* `test/i18n-contributed-nav-coverage.test.ts`, which runs the rule over a
2189+
* bundle carrying every contributed key this walk expects.
2190+
*
2191+
* Not seen, here as there: a contribution registered imperatively from a
2192+
* plugin's `init` (`engine.registerAppNavContribution`) is not metadata;
2193+
* `packages/cli/scripts/check-app-nav-i18n.mjs` boots a composition to judge
2194+
* that population.
2195+
*/
2196+
function navigationContributionsOf(config: any): any[] {
2197+
const out: any[] = [];
2198+
const add = (list: unknown): void => {
2199+
if (Array.isArray(list)) out.push(...list);
2200+
};
2201+
add(config?.manifest?.navigationContributions);
2202+
const packages: unknown[] = Array.isArray(config?.packages) ? config.packages : [];
2203+
for (const entry of packages) add((entry as any)?.manifest?.navigationContributions);
2204+
return out;
2205+
}
2206+
21492207
// ─── Filter + bundle assembly ──────────────────────────────────────────
21502208

21512209
function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean {
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
2+
//
3+
// objectstack#24993 — a navigation item a package CONTRIBUTES into another
4+
// package's app (`manifest.navigationContributions`, ADR-0029 D7) owes its
5+
// translation under the TARGET app, like an item the app declares itself.
6+
//
7+
// The runtime folds a contributed item into the target app's navigation and
8+
// `translateApp` resolves its label from `apps.<target>.navigation.<id>.label`;
9+
// `translation-target-unknown` accepts that key (#18203). The walker read
10+
// `apps[].navigation` only, so the key was never expected: deleting a
11+
// contributed item's zh-CN label left `os lint --strict` and `os i18n check`
12+
// green, measured on HotCRM's `nav_case`, while the same deletion on one of
13+
// the app's own items reported `i18n/missing-navigation`.
14+
//
15+
// The fixture is the card's shape: a service module contributing into the
16+
// app's group and adding a group of its own, composed module-first with
17+
// `manifest: 'preserve'`, so the contributions live only in the module's
18+
// `packages[]` body. Both doors hand `computeI18nCoverage` the
19+
// `authoringRuleUnionStack` of that composition, which is what these pins do.
20+
21+
import { describe, it, expect } from 'vitest';
22+
import { composeStacks, defineStack } from '@objectstack/spec';
23+
import { TRANSLATION_TARGET_UNKNOWN, validateTranslationReferences } from '@objectstack/lint';
24+
25+
import { collectExpectedEntries } from '../src/utils/i18n-extract.js';
26+
import { computeI18nCoverage } from '../src/utils/i18n-coverage.js';
27+
import { authoringRuleUnionStack } from '../src/utils/stack-collections.js';
28+
29+
const I18N = { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'], fallbackLocale: 'en' };
30+
const engines = { protocol: '^18' };
31+
32+
const appManifest = {
33+
id: 'com.example.cnav.app', name: 'Contributed Nav App', namespace: 'cnv', version: '1.0.0', type: 'app', engines,
34+
};
35+
36+
/** Into the app's own group, and a group of the module's own with a child. */
37+
const CONTRIBUTIONS = [
38+
{
39+
app: 'cnv_desk',
40+
group: 'grp_service',
41+
items: [{ id: 'nav_case', type: 'object', objectName: 'cnv_case', label: 'Cases' }],
42+
},
43+
{
44+
app: 'cnv_desk',
45+
items: [{
46+
id: 'grp_knowledge',
47+
type: 'group',
48+
label: 'Knowledge',
49+
children: [{ id: 'nav_article', type: 'object', objectName: 'cnv_article', label: 'Articles' }],
50+
}],
51+
},
52+
];
53+
54+
const svcManifest = {
55+
id: 'com.example.cnav.svc', name: 'Contributed Nav Service', namespace: 'cnv', version: '1.0.0', type: 'module', engines,
56+
navigationContributions: CONTRIBUTIONS,
57+
};
58+
59+
const object = (name: string, label: string) => ({
60+
name, label, pluralLabel: label, sharingModel: 'private',
61+
fields: { name: { name: 'name', type: 'text', label: 'Name', required: true } },
62+
});
63+
64+
const app = {
65+
name: 'cnv_desk',
66+
label: 'Desk',
67+
navigation: [{
68+
id: 'grp_service',
69+
type: 'group',
70+
label: 'Service',
71+
children: [{ id: 'nav_accounts', type: 'object', objectName: 'cnv_account', label: 'Accounts' }],
72+
}],
73+
};
74+
75+
/** The zh-CN navigation labels, every one present; a case deletes one. */
76+
const OWN_NAV_ZH = { grp_service: { label: '服务' }, nav_accounts: { label: '客户' } };
77+
const CONTRIBUTED_NAV_ZH = { nav_case: { label: '工单' }, grp_knowledge: { label: '知识' }, nav_article: { label: '文章' } };
78+
79+
const without = (labels: Record<string, unknown>, id?: string) =>
80+
Object.fromEntries(Object.entries(labels).filter(([key]) => key !== id));
81+
82+
/**
83+
* The app package's pack carries every navigation label, the contributed ones
84+
* included — the layout HotCRM's packs were measured in (#18203). A pack's
85+
* `apps.APP` entry requires the app's own `label`, so a module could ship the
86+
* contributed labels itself only by restating it.
87+
*/
88+
function stacks(drop?: string) {
89+
const appStack = defineStack({
90+
manifest: appManifest,
91+
objects: [object('cnv_account', 'Account')],
92+
apps: [app],
93+
i18n: I18N,
94+
translations: [{
95+
'zh-CN': { apps: { cnv_desk: { label: '服务台', navigation: without({ ...OWN_NAV_ZH, ...CONTRIBUTED_NAV_ZH }, drop) } } },
96+
}],
97+
} as any);
98+
const svcStack = defineStack({
99+
manifest: svcManifest,
100+
objects: [object('cnv_case', 'Case'), object('cnv_article', 'Article')],
101+
i18n: I18N,
102+
} as any);
103+
return { appStack, svcStack };
104+
}
105+
106+
/** Module first, as HotCRM composes it: the top-level manifest is the app's. */
107+
function composed(drop?: string): Record<string, unknown> {
108+
const { appStack, svcStack } = stacks(drop);
109+
return authoringRuleUnionStack(composeStacks([svcStack, appStack], { manifest: 'preserve' }) as any);
110+
}
111+
112+
const navEntries = (config: any) =>
113+
collectExpectedEntries(config).filter((e) => e.path[0] === 'apps' && e.path[2] === 'navigation');
114+
115+
const zhNavIssueKeys = (config: any) =>
116+
computeI18nCoverage(config, { strict: true }).issues
117+
.filter((i) => i.locale === 'zh-CN' && i.source === 'navigation')
118+
.map((i) => i.key);
119+
120+
describe('what the walker expects from a contributed navigation item (#24993)', () => {
121+
it('reads the contributions from the module\'s package body alone — the fixture is the card\'s shape', () => {
122+
const stack = composed() as any;
123+
expect(stack.manifest.navigationContributions).toBeUndefined();
124+
expect(stack.packages.map((p: any) => p.manifest.navigationContributions?.length ?? 0)).toEqual([2, 0]);
125+
});
126+
127+
it('expects each contributed item under the TARGET app, seeded with its own label, after the app\'s own items', () => {
128+
const entries = navEntries(composed());
129+
expect(entries.map((e) => e.path.join('.'))).toEqual([
130+
'apps.cnv_desk.navigation.grp_service.label',
131+
'apps.cnv_desk.navigation.nav_accounts.label',
132+
'apps.cnv_desk.navigation.nav_case.label',
133+
'apps.cnv_desk.navigation.grp_knowledge.label',
134+
'apps.cnv_desk.navigation.nav_article.label',
135+
]);
136+
expect(entries[2]).toMatchObject({ sourceValue: 'Cases', inline: 'Cases', source: 'navigation', appName: 'cnv_desk' });
137+
expect(entries[4]).toMatchObject({ sourceValue: 'Articles', inline: 'Articles', source: 'navigation', appName: 'cnv_desk' });
138+
});
139+
140+
it('reads a single stack\'s own manifest too, keyed under an app the stack only contributes into', () => {
141+
const single = defineStack({
142+
manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] },
143+
objects: [object('cnv_case', 'Case')],
144+
} as any);
145+
expect(navEntries(single).map((e) => e.path.join('.'))).toEqual(['apps.crm_enterprise.navigation.nav_case.label']);
146+
});
147+
148+
it('expects each key once when the top-level manifest is the contributor\'s copy', () => {
149+
const { appStack, svcStack } = stacks();
150+
const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any) as any;
151+
// Both carriers hold the module's contributions here.
152+
expect(appLast.manifest.navigationContributions).toHaveLength(2);
153+
expect(navEntries(appLast).map((e) => e.path.join('.'))).toEqual([
154+
'apps.cnv_desk.navigation.grp_service.label',
155+
'apps.cnv_desk.navigation.nav_accounts.label',
156+
'apps.cnv_desk.navigation.nav_case.label',
157+
'apps.cnv_desk.navigation.grp_knowledge.label',
158+
'apps.cnv_desk.navigation.nav_article.label',
159+
]);
160+
});
161+
162+
it('keeps the app\'s own entry when a contribution reuses one of its ids', () => {
163+
const config = {
164+
apps: [app],
165+
manifest: { navigationContributions: [{ app: 'cnv_desk', items: [{ id: 'nav_accounts', type: 'url', url: '/x', label: 'Elsewhere' }] }] },
166+
};
167+
const entries = navEntries(config);
168+
expect(entries.map((e) => e.path.join('.'))).toEqual([
169+
'apps.cnv_desk.navigation.grp_service.label',
170+
'apps.cnv_desk.navigation.nav_accounts.label',
171+
]);
172+
expect(entries[1].sourceValue).toBe('Accounts');
173+
});
174+
});
175+
176+
describe('the coverage gate reports a missing contributed item as it reports an own one (#24993)', () => {
177+
it('reports no navigation gap while every label is translated', () => {
178+
expect(zhNavIssueKeys(composed())).toEqual([]);
179+
});
180+
181+
it('reports a deleted contributed item', () => {
182+
expect(zhNavIssueKeys(composed('nav_case'))).toEqual(['apps.cnv_desk.navigation.nav_case.label']);
183+
});
184+
185+
it('reports a deleted child of a contributed group', () => {
186+
expect(zhNavIssueKeys(composed('nav_article'))).toEqual(['apps.cnv_desk.navigation.nav_article.label']);
187+
});
188+
189+
it('control: reports a deleted item the app declares itself, as before', () => {
190+
expect(zhNavIssueKeys(composed('nav_accounts'))).toEqual(['apps.cnv_desk.navigation.nav_accounts.label']);
191+
});
192+
193+
it('reports the deleted key once when both carriers hold the contribution', () => {
194+
const { appStack, svcStack } = stacks('nav_case');
195+
const appLast = authoringRuleUnionStack(composeStacks([appStack, svcStack], { manifest: 'preserve' }) as any);
196+
expect(zhNavIssueKeys(appLast)).toEqual(['apps.cnv_desk.navigation.nav_case.label']);
197+
});
198+
});
199+
200+
describe('every contributed key the walker expects is one translation-target-unknown accepts (#24993)', () => {
201+
/** A zh-CN bundle carrying exactly the navigation keys the walker expects. */
202+
const bundleOf = (config: any) => {
203+
const navigation: Record<string, { label: string }> = {};
204+
const apps: Record<string, { navigation: typeof navigation }> = {};
205+
for (const entry of navEntries(config)) {
206+
const [, appName, , id] = entry.path;
207+
apps[appName] ??= { navigation: {} };
208+
apps[appName].navigation[id] = { label: 'x' };
209+
}
210+
return { 'zh-CN': { apps } };
211+
};
212+
const unknownTargets = (stack: any, bundle: unknown) =>
213+
validateTranslationReferences({ ...stack, translations: [bundle] })
214+
.filter((f) => f.rule === TRANSLATION_TARGET_UNKNOWN)
215+
.map((f) => f.path);
216+
217+
it('accepts every expected key on the composed stack', () => {
218+
const stack = composed();
219+
const bundle = bundleOf(stack);
220+
// Not vacuous: the bundle carries the contributed ids, not only the app's own.
221+
expect(Object.keys(bundle['zh-CN'].apps.cnv_desk.navigation)).toEqual([
222+
'grp_service', 'nav_accounts', 'nav_case', 'grp_knowledge', 'nav_article',
223+
]);
224+
expect(unknownTargets(stack, bundle)).toEqual([]);
225+
});
226+
227+
it('accepts the expected key under an app the stack only contributes into', () => {
228+
const single = defineStack({
229+
manifest: { ...svcManifest, navigationContributions: [{ app: 'crm_enterprise', items: CONTRIBUTIONS[0].items }] },
230+
objects: [object('cnv_case', 'Case')],
231+
} as any) as any;
232+
const bundle = bundleOf(single);
233+
expect(Object.keys(bundle['zh-CN'].apps)).toEqual(['crm_enterprise']);
234+
expect(unknownTargets(single, bundle)).toEqual([]);
235+
});
236+
237+
it('control: still refuses a navigation id nothing declares or contributes', () => {
238+
const stack = composed();
239+
const bundle = { 'zh-CN': { apps: { cnv_desk: { navigation: { nav_ghost: { label: 'x' } } } } } };
240+
expect(unknownTargets(stack, bundle)).toEqual(['translations[0]["zh-CN"].apps.cnv_desk.navigation.nav_ghost']);
241+
});
242+
});

0 commit comments

Comments
 (0)