|
93 | 93 | * globalActions.<action>.params.<param>.* / .resultDialog.* (same shape as object actions) |
94 | 94 | * apps.<app>.label / .description |
95 | 95 | * 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`) |
96 | 99 | * dashboards.<dash>.label / .description |
97 | 100 | * dashboards.<dash>.widgets.<w>.title / .description |
98 | 101 | * datasets.<dataset>.label / .description |
@@ -1451,6 +1454,22 @@ export function collectExpectedEntries( |
1451 | 1454 | walkNavigation(nav, appName, out); |
1452 | 1455 | } |
1453 | 1456 |
|
| 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 | + |
1454 | 1473 | // ── Dashboards + widgets ───────────────────────────────────────── |
1455 | 1474 | const dashboards: any[] = Array.isArray(config?.dashboards) ? config.dashboards : []; |
1456 | 1475 | for (const dash of dashboards) { |
@@ -2146,6 +2165,45 @@ function walkNavigation(nav: any[], appName: string, out: ExpectedEntry[]): void |
2146 | 2165 | } |
2147 | 2166 | } |
2148 | 2167 |
|
| 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 | + |
2149 | 2207 | // ─── Filter + bundle assembly ────────────────────────────────────────── |
2150 | 2208 |
|
2151 | 2209 | function passesFilter(entry: ExpectedEntry, filter?: RegExp): boolean { |
|
0 commit comments