diff --git a/.changeset/19349-translation-target-collection-artifact-reach.md b/.changeset/19349-translation-target-collection-artifact-reach.md new file mode 100644 index 00000000000..944327e83f4 --- /dev/null +++ b/.changeset/19349-translation-target-collection-artifact-reach.md @@ -0,0 +1,17 @@ +--- +"@objectstack/lint": patch +--- + +`translation-target-unknown` no longer reports the locale keys a package ships for a view, page, action, app, dashboard or flow a SIBLING package of the same artifact declares. Six collection rungs built their universe from the top-level collection alone, while the same file already read `packages[].manifest.…` for `navigationContributions` (#18442), `objectExtensions` (#18441) and `objects` (#19064) — so the capability was present and these rungs did not use it (#19349). + +`os build` runs the rule table per PACKAGE as well as over the union (`compile.ts` step 3b-ii): each package body is judged as its own stack with the artifact's `packages[]` beside it as resolution context (`packageBodyAsStack`, #16611). On that leg each `stack.` holds ONE package's declarations. Measured on a throwaway probe before anything was touched, each level produced exactly one `error`, at `translations[0]["zh-CN"].dashboards.crm_overview`, `….flows.lead_conversion`, `….globalActions.export_all`, `….objects.crm_order._views.board`, `….objects.crm_order._tabs.mine` and `….apps.crm_app` — each carrying a remedy (`or drop it`) that deletes a translation the runtime honours, at a severity that FAILS the run. + +**The carrier is proven rather than assumed.** Every one of these keys carries disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, which is precisely what puts it inside `ASSEMBLED_PACKAGE_BODY_DISPOSITIONS` and so inside an ADR-0130 D4 entry's assembled body — the same proof the `objectExtensions` and `objects` folds rest on. ADR-0130 makes the release artifact the co-ownership boundary, so the miss is the RUN's blind spot and not the author's mistake. + +**Records, not names — and for three different reasons, not one assumption applied six times.** `dashboards`, `flows` and `apps` are keyed by their own name and carry a sub-rung derived from the record (widget ids and header `actionUrl`s, screen node ids and their field names, navigation ids), so a name-only fold would resolve the top key and then judge that sub-rung against an empty set. The `actions` record is itself read downstream by `checkActionParams`, and it carries the owner that keeps an object-bound action under `objects.._actions` instead of making it globally addressable. `views` and `pages` have no bundle rung of their own at all — they contribute `_views`, `_sections` and `_tabs` facts under the object they bind to — so the record is the only thing carrying both the fact and its binding. + +**`apps` is the half #18442 did not cover.** That change reads `navigationContributions`, so an app became addressable only where THIS package contributes into it; a sibling's `apps[]` declaration was invisible either way. With no contribution the app NAME was the orphan; with one, the name resolved through #18442 while the owner's own navigation ids were orphans — and were diagnosed "this stack contributes no such item", advising a move to a package sitting in the same artifact. Folding the records before the contributed-only pass closes both halves and restores the declared-app diagnosis, while an app owned OUTSIDE the artifact keeps #18442's wording. + +**The controls, which are what make this a narrowing and not a hole.** Every rung pins both directions side by side: the same bundle judged ALONE still errors (so "no findings" cannot be confused with the rung going quiet); a name no entry of the artifact declares still errors with its rule id and a remedy that now enumerates what the artifact provides; every sub-rung stays judged against the sibling's declaration, so a typo under a resolved dashboard, flow, screen, app or object is still an `error`; an object-bound sibling action keyed under `globalActions` still errors with its routing message; where both packages declare the same name the declaration being judged keeps the slot, so a sibling's widget ids do not become addressable under this package's dashboard; an entry with no readable body (a segment reference) makes nothing addressable on any rung; and the single-`defineStack` shape is untouched, because all six are stack collections with no `stack.manifest` form to read. + +No schema moved, no export moved, and no accept set moved: this is a lint rule's false-positive set narrowing. `Clause-②: no` diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index a163819b5e2..b03e448e85a 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -1416,6 +1416,461 @@ describe('validateTranslationReferences — an object a SIBLING package of the a }); }); +/** + * ⭐ #19349 — the remaining COLLECTION rungs of the same rule, on the same + * per-package leg, closed by the same carrier #19064 closed the object rung on. + * + * `os build` judges each package body as its own stack + * (`packageBodyAsStack(body, entries)`, `compile.ts` step 3b-ii). On that leg + * `stack.views` / `.pages` / `.actions` / `.apps` / `.dashboards` / `.flows` + * each hold ONE package's declarations, so a package translating something a + * SIBLING package of the same artifact declares had the key reported + * `translation-target-unknown` at `error` — the remedy advising a deletion the + * runtime honours. Measured on a throwaway probe before anything was touched: + * one `error` per level, at `translations[0]["zh-CN"].dashboards.crm_overview`, + * `….flows.lead_conversion`, `….globalActions.export_all`, + * `….objects.crm_order._views.board`, `….objects.crm_order._tabs.mine` and + * `….apps.crm_app`. + * + * ⛔ Not a new resolution-context decision, and not a new carrier: every one of + * these six keys carries disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, + * which is what puts it inside `ASSEMBLED_PACKAGE_BODY_DISPOSITIONS` and so + * inside an ADR-0130 D4 entry's assembled body — the same proof + * `objectExtensionsByTarget` (#18441) and `artifactProvidedRecords` (#19064) + * rest on. ADR-0130 makes the release artifact the co-ownership boundary. + * + * ## Why every one of these folds RECORDS, and ⛔ never names + * + * The judgement #19064 asked the taker to make per level, made per level: + * + * - `dashboards`, `flows`, `apps` are keyed by their own name and carry a + * SUB-RUNG derived from the record (widget ids and header `actionUrl`s, + * screen node ids and their `config.fields[].name`, navigation ids). A + * name-only fold would resolve the top key and then judge that sub-rung + * against an EMPTY set, reporting every child key as an orphan — the trap + * #19064 recorded one rung up, moved one rung down. + * - `actions` is keyed by name, but the stored RECORD is itself read + * downstream: `checkActionParams` judges `params.` off it. Folding a + * bare name would have nothing to give it. + * - `views` and `pages` have no top-level bundle rung at ALL — they are pure + * fact contributors into `objects.` (`_views`, `_sections`, `_tabs`). + * There is no name to fold: the record is the only thing that carries both + * the fact and the object it binds to. + * + * ⇒ all six fold records, for three different reasons — ⛔ not one assumption + * applied six times. + */ +describe('validateTranslationReferences — collection rungs a SIBLING package of the artifact declares (#19349)', () => { + /** + * `examples/app-multi-package`'s shape: `core` declares, `orders` translates. + * + * ⚠️ `views` and `pages` here are bound to `crm_order` — the object the + * TRANSLATING package declares — deliberately. That isolates these rungs from + * #19064's object fold: if the object rung regressed, these cases would fail + * for the wrong reason and the `_views` / `_tabs` reading would be worthless. + */ + const SIBLING_BODY = { + id: 'com.example.multi.core', + views: [{ name: 'board', object: 'crm_order' }], + pages: [ + { name: 'order_console', object: 'crm_order', interfaceConfig: { userFilters: { tabs: [{ name: 'mine' }] } } }, + ], + // Two actions: one object-less (the `globalActions` rung) and one bound to + // the translating package's own object (the routing control below). + actions: [ + { name: 'export_all', label: 'Export all' }, + { name: 'recalc_totals', label: 'Recalculate', objectName: 'crm_order' }, + ], + apps: [{ name: 'crm_app', navigation: [{ id: 'leads' }] }], + dashboards: [ + { name: 'crm_overview', widgets: [{ id: 'pipeline' }], actions: [{ actionUrl: '/orders/new' }] }, + ], + flows: [ + { name: 'lead_conversion', nodes: [{ id: 'qualify', type: 'screen', config: { fields: [{ name: 'amount' }] } }] }, + ], + }; + + const ownBody = (data: Record) => ({ + id: 'com.example.multi.orders', + objects: [{ name: 'crm_order', fields: { number: { type: 'text' } } }], + translations: [{ 'zh-CN': data }], + }); + /** `packageBodyAsStack(body, entries)` — the body IS its own manifest. */ + const asPerPackageLeg = (body: Record, entries: unknown[]) => ({ + ...body, + manifest: body, + packages: entries, + }); + /** The artifact leg: this package beside the sibling that declares. */ + const perPackageLeg = (data: Record) => { + const body = ownBody(data); + return validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }, { manifest: SIBLING_BODY }])); + }; + /** ⭐ The reproduction, kept as the control: the same bundle, judged ALONE. */ + const aloneLeg = (data: Record) => { + const body = ownBody(data); + return validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }])); + }; + const onlyPath = (findings: ReturnType) => findings.map((f) => f.path); + + describe('dashboards', () => { + const KEY = { dashboards: { crm_overview: { widgets: { pipeline: '管道' } } } }; + + it('CONTROL — judged ALONE the very same bundle still errors', () => { + const findings = aloneLeg(KEY); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: TRANSLATION_TARGET_UNKNOWN, + severity: 'error', + path: 'translations[0]["zh-CN"].dashboards.crm_overview', + }); + }); + + it('accepts the dashboard key, its widget ids and its header actions', () => { + expect(perPackageLeg(KEY)).toEqual([]); + expect(perPackageLeg({ dashboards: { crm_overview: { actions: { '/orders/new': '新建' } } } })).toEqual([]); + }); + + it('NON-DEGENERACY — a dashboard NO package of the artifact declares still errors', () => { + const findings = perPackageLeg({ dashboards: { zzz_no_dashboard: { widgets: { a: 'x' } } } }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: TRANSLATION_TARGET_UNKNOWN, + severity: 'error', + path: 'translations[0]["zh-CN"].dashboards.zzz_no_dashboard', + }); + // The remedy enumerates what the ARTIFACT provides — the evidence the + // fold reached this run at all, and not a vacuous hint assertion. + expect(findings[0].hint).toContain('Defined dashboards: crm_overview.'); + }); + + it('NON-DEGENERACY — the sub-rung stays judged against the SIBLING declaration', () => { + const widget = perPackageLeg({ dashboards: { crm_overview: { widgets: { zzz_gone: 'x' } } } }); + expect(onlyPath(widget)).toEqual(['translations[0]["zh-CN"].dashboards.crm_overview.widgets.zzz_gone']); + expect(widget[0].hint).toContain('Declared widget ids: pipeline.'); + const action = perPackageLeg({ dashboards: { crm_overview: { actions: { '/zzz/gone': 'x' } } } }); + expect(onlyPath(action)).toEqual(['translations[0]["zh-CN"].dashboards.crm_overview.actions./zzz/gone']); + expect(action[0].hint).toContain('Declared header actions: /orders/new.'); + }); + + /** + * ⭐ The precedence pin, and a false-negative control in its own right: the + * declaration this leg is JUDGING keeps the slot, exactly as #19064 decided + * for objects and #18441 one collection over. A sibling's same-named + * dashboard must not make the sibling's widget ids addressable here. + */ + it("keeps the stack's OWN declaration when a sibling declares the same dashboard name", () => { + const body = { + id: 'com.example.multi.orders', + dashboards: [{ name: 'crm_overview', widgets: [{ id: 'own_widget' }] }], + translations: [ + { 'zh-CN': { dashboards: { crm_overview: { widgets: { own_widget: '本包', pipeline: '兄弟包' } } } } }, + ], + }; + const findings = validateTranslationReferences( + asPerPackageLeg(body, [{ manifest: body }, { manifest: SIBLING_BODY }]), + ); + expect(onlyPath(findings)).toEqual([ + 'translations[0]["zh-CN"].dashboards.crm_overview.widgets.pipeline', + ]); + }); + }); + + describe('flows', () => { + const KEY = { flows: { lead_conversion: { screens: { qualify: { fields: { amount: '金额' } } } } } }; + + it('CONTROL — judged ALONE the very same bundle still errors', () => { + const findings = aloneLeg(KEY); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: TRANSLATION_TARGET_UNKNOWN, + severity: 'error', + path: 'translations[0]["zh-CN"].flows.lead_conversion', + }); + }); + + it('accepts the flow key, its screen node ids and their field names', () => { + expect(perPackageLeg(KEY)).toEqual([]); + }); + + it('NON-DEGENERACY — a flow NO package of the artifact declares still errors', () => { + const findings = perPackageLeg({ flows: { zzz_no_flow: {} } }); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].flows.zzz_no_flow']); + expect(findings[0].hint).toContain('Defined flows: lead_conversion.'); + }); + + it('NON-DEGENERACY — both sub-rungs stay judged against the SIBLING declaration', () => { + const screen = perPackageLeg({ flows: { lead_conversion: { screens: { zzz_gone: {} } } } }); + expect(onlyPath(screen)).toEqual(['translations[0]["zh-CN"].flows.lead_conversion.screens.zzz_gone']); + expect(screen[0].hint).toContain('Declared screen node ids: qualify.'); + const field = perPackageLeg({ + flows: { lead_conversion: { screens: { qualify: { fields: { zzz_gone: 'x' } } } } }, + }); + expect(onlyPath(field)).toEqual([ + 'translations[0]["zh-CN"].flows.lead_conversion.screens.qualify.fields.zzz_gone', + ]); + expect(field[0].hint).toContain('Declared screen field names: amount.'); + }); + }); + + describe('globalActions (the `actions` collection)', () => { + const KEY = { globalActions: { export_all: { label: '全部导出' } } }; + + it('CONTROL — judged ALONE the very same bundle still errors', () => { + const findings = aloneLeg(KEY); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: TRANSLATION_TARGET_UNKNOWN, + severity: 'error', + path: 'translations[0]["zh-CN"].globalActions.export_all', + }); + }); + + it('accepts an object-less action a sibling package declares', () => { + expect(perPackageLeg(KEY)).toEqual([]); + }); + + it('NON-DEGENERACY — an action NO package of the artifact declares still errors', () => { + const findings = perPackageLeg({ globalActions: { zzz_no_action: { label: 'x' } } }); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].globalActions.zzz_no_action']); + expect(findings[0].hint).toContain('Object-less actions: export_all.'); + }); + + /** + * ⭐ The ROUTING control — the one false negative this rung can produce that + * the others cannot. A sibling action BOUND to an object resolves under + * `objects.._actions.` and never under `globalActions`, so the + * fold must place it by its owner rather than make it globally addressable. + * Widening the `globalActions` map instead would accept a key the resolver + * never reads, which is the mirror image of the orphan this rule reports. + */ + it('routes an object-BOUND sibling action to its owner, not to `globalActions`', () => { + expect(perPackageLeg({ objects: { crm_order: { _actions: { recalc_totals: { label: '重算' } } } } })).toEqual([]); + const findings = perPackageLeg({ globalActions: { recalc_totals: { label: '重算' } } }); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].globalActions.recalc_totals']); + expect(findings[0].message).toContain('is bound to object "crm_order"'); + expect(findings[0].hint).toContain('Move these keys under `objects.crm_order._actions.recalc_totals`.'); + }); + + /** The stored RECORD is what `checkActionParams` reads — so it is folded. */ + it('judges `params` off the SIBLING record rather than an empty set', () => { + const body = { + id: 'com.example.multi.orders', + translations: [{ 'zh-CN': { globalActions: { export_all: { params: { zzz_gone: { label: 'x' } } } } } }], + }; + const sibling = { + id: 'com.example.multi.core', + actions: [{ name: 'export_all', params: [{ name: 'format' }] }], + }; + const findings = validateTranslationReferences( + asPerPackageLeg(body, [{ manifest: body }, { manifest: sibling }]), + ); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].globalActions.export_all.params.zzz_gone'); + const ok = { + id: 'com.example.multi.orders', + translations: [{ 'zh-CN': { globalActions: { export_all: { params: { format: { label: '格式' } } } } } }], + }; + expect( + validateTranslationReferences(asPerPackageLeg(ok, [{ manifest: ok }, { manifest: sibling }])), + ).toEqual([]); + }); + }); + + describe('views and pages — pure fact contributors under an object', () => { + it('CONTROL — judged ALONE both rungs still error', () => { + expect(onlyPath(aloneLeg({ objects: { crm_order: { _views: { board: { label: '看板' } } } } }))).toEqual([ + 'translations[0]["zh-CN"].objects.crm_order._views.board', + ]); + expect(onlyPath(aloneLeg({ objects: { crm_order: { _tabs: { mine: { label: '我的' } } } } }))).toEqual([ + 'translations[0]["zh-CN"].objects.crm_order._tabs.mine', + ]); + }); + + it("accepts a view a sibling declares at the stack level over THIS package's object", () => { + expect(perPackageLeg({ objects: { crm_order: { _views: { board: { label: '看板' } } } } })).toEqual([]); + }); + + it("accepts a filter-preset tab a sibling's page declares over THIS package's object", () => { + expect(perPackageLeg({ objects: { crm_order: { _tabs: { mine: { label: '我的' } } } } })).toEqual([]); + }); + + it('NON-DEGENERACY — a view and a tab neither package declares still error', () => { + const view = perPackageLeg({ objects: { crm_order: { _views: { zzz_gone: { label: 'x' } } } } }); + expect(onlyPath(view)).toEqual(['translations[0]["zh-CN"].objects.crm_order._views.zzz_gone']); + expect(view[0].hint).toContain('Declared views: board.'); + const tab = perPackageLeg({ objects: { crm_order: { _tabs: { zzz_gone: { label: 'x' } } } } }); + expect(onlyPath(tab)).toEqual(['translations[0]["zh-CN"].objects.crm_order._tabs.zzz_gone']); + expect(tab[0].hint).toContain('Declared tabs: mine.'); + }); + + /** + * A sibling's page contributes `_sections` through the SAME component walk + * the stack's own pages go through — one collector, so the two cannot + * disagree about which anchors register a section name. + */ + it("accepts a `record:details` section a sibling's page declares", () => { + const sibling = { + id: 'com.example.multi.core', + pages: [ + { + name: 'order_detail', + object: 'crm_order', + regions: [ + { components: [{ type: 'record:details', properties: { sections: [{ name: 'billing' }] } }] }, + ], + }, + ], + }; + const body = ownBody({ objects: { crm_order: { _sections: { billing: { label: '账单' } } } } }); + expect( + validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }, { manifest: sibling }])), + ).toEqual([]); + // …and the same bundle judged ALONE still errors. + expect( + onlyPath(validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }]))), + ).toEqual(['translations[0]["zh-CN"].objects.crm_order._sections.billing']); + }); + }); + + describe('apps — the half #18442 did NOT cover', () => { + /** + * ⚠️ #18442 closed the CONTRIBUTED-app level: an app becomes addressable + * when THIS package contributes into it through + * `manifest.navigationContributions`. That reads contributions, never + * declarations — so a sibling's `apps[]` entry was invisible on both + * halves, measured rather than assumed: + * + * 1. contributing nothing, the app NAME itself was the orphan; + * 2. contributing something, the name resolved through #18442 but the + * sibling's own navigation ids were orphans — and were diagnosed + * "this stack contributes no such item", advising a move to a package + * that is right here in the artifact. + * + * Both are closed by folding the sibling's app RECORDS, which also lands + * them in `apps` BEFORE the contributed-only pass, so such an app is no + * longer flagged `contributedOnly` and gets the declared-app diagnosis. + */ + const KEY = { apps: { crm_app: { navigation: { leads: '线索' } } } }; + + it('CONTROL — judged ALONE the very same bundle still errors, at the APP rung', () => { + const findings = aloneLeg(KEY); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + rule: TRANSLATION_TARGET_UNKNOWN, + severity: 'error', + path: 'translations[0]["zh-CN"].apps.crm_app', + }); + }); + + it('half 1 — accepts an app a sibling declares while this package contributes nothing', () => { + expect(perPackageLeg(KEY)).toEqual([]); + }); + + it('half 2 — a contributor sees the OWNER declaration beside its own contribution', () => { + const body = { + id: 'com.example.multi.orders', + navigationContributions: [{ app: 'crm_app', items: [{ id: 'orders' }] }], + translations: [{ 'zh-CN': { apps: { crm_app: { navigation: { leads: '线索', orders: '订单' } } } } }], + }; + expect( + validateTranslationReferences(asPerPackageLeg(body, [{ manifest: body }, { manifest: SIBLING_BODY }])), + ).toEqual([]); + }); + + it('NON-DEGENERACY — an app NO package of the artifact declares or is contributed into still errors', () => { + const findings = perPackageLeg({ apps: { zzz_no_app: { navigation: { a: 'x' } } } }); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].apps.zzz_no_app']); + expect(findings[0].hint).toContain('Apps this stack defines or contributes into: crm_app.'); + }); + + /** + * ⭐ Both the false-negative control for the nav rung AND the pin on the + * DIAGNOSIS: the sibling's declaration is readable here, so an unresolved + * id gets the declared-app wording, ⛔ never the contributed-only wording + * that would send the author to a package sitting in the same artifact. + */ + it('NON-DEGENERACY — a nav id nothing declares still errors, with the DECLARED-app diagnosis', () => { + const findings = perPackageLeg({ apps: { crm_app: { navigation: { zzz_gone: 'x' } } } }); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].apps.crm_app.navigation.zzz_gone']); + expect(findings[0].hint).toContain('Declared navigation ids: leads.'); + expect(findings[0].message).toContain('does not declare'); + expect(findings[0].message).not.toContain('contributes into'); + }); + + /** + * ⛔ …and the #18442 contributed-only path is NOT collaterally removed: an + * app whose owner is outside this artifact keeps its own diagnosis. + */ + it('leaves #18442 intact — an app contributed into but owned OUTSIDE the artifact keeps its wording', () => { + const body = { + id: 'com.example.multi.orders', + navigationContributions: [{ app: 'other_app', items: [{ id: 'orders' }] }], + translations: [{ 'zh-CN': { apps: { other_app: { navigation: { orders: '订单', zzz_gone: 'x' } } } } }], + }; + const findings = validateTranslationReferences( + asPerPackageLeg(body, [{ manifest: body }, { manifest: SIBLING_BODY }]), + ); + expect(onlyPath(findings)).toEqual(['translations[0]["zh-CN"].apps.other_app.navigation.zzz_gone']); + expect(findings[0].message).toContain('contributes into'); + }); + }); + + describe('the widening is not a path', () => { + /** + * ⛔ Only the ADR-0130 D4 entry shape is read. A segment reference carries + * no manifest content, and inventing a name for one would be the mistake + * this context must not make — a name in here SILENCES the ladder. + */ + it('an entry with no readable body makes nothing addressable, on every rung', () => { + const body = ownBody({ + dashboards: { crm_overview: {} }, + flows: { lead_conversion: {} }, + globalActions: { export_all: {} }, + apps: { crm_app: {} }, + objects: { crm_order: { _views: { board: {} }, _tabs: { mine: {} } } }, + }); + const findings = validateTranslationReferences( + asPerPackageLeg(body, [{ manifest: body }, { ref: 'com.example.multi.core@1.0.0', integrity: 'sha512-zzz' }]), + ); + expect(onlyPath(findings).sort()).toEqual( + [ + 'translations[0]["zh-CN"].apps.crm_app', + 'translations[0]["zh-CN"].dashboards.crm_overview', + 'translations[0]["zh-CN"].flows.lead_conversion', + 'translations[0]["zh-CN"].globalActions.export_all', + 'translations[0]["zh-CN"].objects.crm_order._tabs.mine', + 'translations[0]["zh-CN"].objects.crm_order._views.board', + ].sort(), + ); + }); + + /** + * The single-`defineStack` shape is untouched: all six are STACK + * collections, not manifest keys, so there is no `stack.manifest.` + * form to read and a bundle keyed to a name nothing declares still errors. + */ + it('leaves the single-stack shape alone — no `packages[]`, no widening', () => { + const findings = validateTranslationReferences({ + objects: [{ name: 'crm_order', fields: { number: { type: 'text' } } }], + translations: [ + { + 'zh-CN': { + dashboards: { crm_overview: {} }, + flows: { lead_conversion: {} }, + globalActions: { export_all: {} }, + apps: { crm_app: {} }, + objects: { crm_order: { _views: { board: {} }, _tabs: { mine: {} } } }, + }, + }, + ], + }); + expect(findings).toHaveLength(6); + expect(findings.every((f) => f.rule === TRANSLATION_TARGET_UNKNOWN && f.severity === 'error')).toBe(true); + }); + }); +}); + describe('validateTranslationReferences — flows (#7646 / #11287)', () => { /** * One stack, shared by every case below — the clean run and the three diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 21d35a8b8de..d6b2706ecbd 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -137,6 +137,26 @@ * | `manifest.navigationContributions` | the contributed items' ids, under the target app — including an app this stack only contributes into and does not declare (#18442) | * | `objectExtensions[]` | the fields and validation rules it merges into the target object (#18441) | * + * ── The artifact reach is not object-only (#19349) ─────────────────────── + * + * Rung 1's `packages[]` reach is the same reach EVERY metadata collection this + * rule judges needs, for the same reason: on the per-package leg + * `stack.` holds one package's declarations, so a package + * translating what a sibling of the same artifact declares was reported as an + * orphan while the runtime resolved the key. {@link artifactProvidedRecords} + * is the one reader of that carrier, and every collection it may be asked for + * is a key whose `COMPOSE_KEY_DISPOSITIONS` disposition puts it inside an + * ADR-0130 D4 entry's assembled body: `objects` (#19064) plus `views`, + * `pages`, `actions`, `apps`, `dashboards` and `flows` (#19349). + * + * ⚠️ `apps` reads BOTH that carrier and `navigationContributions`, and they + * answer different questions — what a sibling DECLARES, and what this package + * CONTRIBUTES. #18442 added the second alone, which left an app addressable + * only where this package contributed into it; the first is what makes a + * sibling's own navigation ids addressable and keeps the contributed-only + * diagnosis for the case it was written for — an app owned outside the + * artifact. + * * ⛔ Each fold widens what a key may RESOLVE against and nothing else: a name * no declaration and no contribution carries is still an orphan and still * errors, on the per-package leg exactly as on the union one. See @@ -834,9 +854,24 @@ function objectExtensionsByTarget(stack: AnyRec): Map { } /** - * Every object RECORD an entry of `packages[]` declares — what THIS ARTIFACT - * provides, beyond the collections the stack in hand carries at its top level - * (#19064). + * Every RECORD of one metadata collection that an entry of `packages[]` + * declares — what THIS ARTIFACT provides, beyond the collection the stack in + * hand carries at its top level (#19064 for `objects`, #19349 for the rest). + * + * ## Which collections may be asked for, and why that is mechanical + * + * Exactly the keys whose `COMPOSE_KEY_DISPOSITIONS` disposition is one of + * `ASSEMBLED_PACKAGE_BODY_DISPOSITIONS` (`concat`, `objects`, `functions`) — + * that membership IS what puts a key inside an ADR-0130 D4 entry's assembled + * body, so it is the same proof {@link objectExtensionsByTarget} rests on one + * collection over rather than a fresh assumption per rung. `objects` carries + * disposition `objects`; `views`, `pages`, `actions`, `apps`, `dashboards` and + * `flows` each carry `concat`. + * + * ⛔ There is no `stack.manifest.` form to read beside this one: + * all of them are STACK collections, not manifest keys — the asymmetry + * {@link contributedNavItemsByApp} is the counter-example to, since + * `navigationContributions` IS a manifest key and so has both carriers. * * ## Why this rung needs the reach at all * @@ -881,12 +916,12 @@ function objectExtensionsByTarget(stack: AnyRec): Map { * asymmetry {@link objectExtensionsByTarget} records one collection over, and * the reason the single-`defineStack` shape is untouched by this fold. */ -function artifactProvidedObjects(stack: AnyRec): AnyRec[] { +function artifactProvidedRecords(stack: AnyRec, collection: string): AnyRec[] { const provided: AnyRec[] = []; for (const entry of recordsOf(stack.packages)) { const body = entry.manifest; if (!isRec(body)) continue; - provided.push(...recordsOf(body.objects)); + provided.push(...recordsOf(body[collection])); } return provided; } @@ -981,7 +1016,7 @@ function buildUniverse(stack: AnyRec): Universe { // definition here — re-deriving that precedence to feed it would be a second // opinion on it. The NAME is addressable either way, which is all this // universe answers — the same decision the extension fold below takes. - for (const obj of artifactProvidedObjects(stack)) { + for (const obj of artifactProvidedRecords(stack, 'objects')) { collectObjectRecord(obj, { ownDeclaration: false }); } @@ -1031,19 +1066,41 @@ function buildUniverse(stack: AnyRec): Universe { } // ── Stack-level views: `_views` names + form-section names ── - for (const view of recordsOf(stack.views)) { + // + // …and the same collection as an entry of `packages[]` declares it (#19349). + // ⛔ There is no name-only fold to weigh here: `views` has no bundle rung of + // its own — a view record contributes FACTS under `objects.` (`_views`, + // and a form container's named `_sections`) — so the record is the only thing + // carrying both the fact and the object it binds to. + // + // Folded through the SAME collector as the declaration loop, and no + // precedence rule is needed: every fact a view contributes is a Set add, so + // where the stack in hand and a sibling declare the same view name the two + // adds are one add. (The two VALUE-carrying maps — fields and actions — are + // where precedence has to be decided, and they are decided where they live.) + for (const view of [...recordsOf(stack.views), ...artifactProvidedRecords(stack, 'views')]) { collectViewRecord(view, factsFor); } // ── Pages: `record:details` sections are the other `_sections` anchor, and // `interfaceConfig.userFilters.tabs` is the ONE `_tabs` anchor ── - const pages = recordsOf(stack.pages); - for (let pi = 0; pi < pages.length; pi++) { + // + // …and, for the same reason and on the same carrier (#19349), the pages an + // entry of `packages[]` declares. Like `views` this rung has no bundle key of + // its own — a page contributes `_tabs` and `_sections` FACTS under the object + // it binds to — so the record is again the only thing there is to fold, and + // both anchors are Set adds that need no precedence rule. + const collectPageRecord = (page: AnyRec) => { // Read off the page ROOT, before the component walk and independent of it: // `interfaceConfig` is not a component, and unlike `regions` it is authored // on source-authored pages too (see `collectPageTabs`). - collectPageTabs(pages[pi], factsFor); - for (const walked of walkPageComponents(pages[pi], `pages[${pi}]`)) { + collectPageTabs(page, factsFor); + // ⚠️ The walk's `path` label is spelled `''`, not an index: this loop reads + // only `objectName` and the component's own `properties.sections`, so no + // finding can carry it — and an index into a list that now spans two + // sources would be a coordinate into nothing. `page-envelope-audit` calls + // the walk the same way, for the same reason. + for (const walked of walkPageComponents(page, '')) { if (!walked.objectName) continue; const props = isRec(walked.component.properties) ? walked.component.properties : undefined; if (!props) continue; @@ -1052,21 +1109,48 @@ function buildUniverse(stack: AnyRec): Universe { if (sectionName) factsFor(walked.objectName).sections.add(sectionName); } } - } + }; + for (const page of recordsOf(stack.pages)) collectPageRecord(page); + for (const page of artifactProvidedRecords(stack, 'pages')) collectPageRecord(page); // ── Actions: object-bound ones join their object; the rest are global ── + // + // …from the stack in hand and from the artifact's other entries (#19349). + // + // ⛔ A name-only fold is wrong here for a reason peculiar to this rung: the + // stored RECORD is itself read downstream — `checkActionParams` judges + // `params.` off it — so folding a bare name would resolve the action + // key and then report every one of its param keys as an orphan. + // + // ⚠️ And the OWNER is read from the record too, which is what keeps the + // widening honest: an action a sibling binds to an object joins that object's + // `_actions`, never `globalActions`. Registering a bound action globally + // would make legal a key the resolver never reads — the mirror image of the + // orphan this rule reports, and a false negative rather than a false positive. const globalActions = new Map(); const actionOwners = new Map(); - for (const action of recordsOf(stack.actions)) { + // `ownDeclaration` carries #19064's decision one collection over: where both + // the stack in hand and a sibling declare the same action name, the record + // this leg is JUDGING keeps the slot, because `checkActionParams` is the only + // consumer of the stored definition and picking the other layer would be a + // second opinion on the registry's precedence. + const collectActionRecord = (action: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => { const actionName = strName(action.name); - if (!actionName) continue; + if (!actionName) return; const owner = strName(action.objectName) ?? strName(action.object); if (owner) { - factsFor(owner).actions.set(actionName, action); + const facts = factsFor(owner); + if (!ownDeclaration && facts.actions.has(actionName)) return; + facts.actions.set(actionName, action); actionOwners.set(actionName, owner); } else { + if (!ownDeclaration && globalActions.has(actionName)) return; globalActions.set(actionName, action); } + }; + for (const action of recordsOf(stack.actions)) collectActionRecord(action, { ownDeclaration: true }); + for (const action of artifactProvidedRecords(stack, 'actions')) { + collectActionRecord(action, { ownDeclaration: false }); } for (const [objectName, facts] of objects) { for (const actionName of facts.actions.keys()) { @@ -1088,9 +1172,9 @@ function buildUniverse(stack: AnyRec): Universe { if (item.children) walkNav(item.children, into); } }; - for (const app of recordsOf(stack.apps)) { + const collectAppRecord = (app: AnyRec) => { const appName = strName(app.name); - if (!appName) continue; + if (!appName) return; const navIds = apps.get(appName) ?? new Set(); walkNav(app.navigation, navIds); for (const area of recordsOf(app.areas)) { @@ -1103,7 +1187,28 @@ function buildUniverse(stack: AnyRec): Universe { // does. This is the population the runtime serves, not the authored array. for (const items of contributedNav.get(appName) ?? []) walkNav(items, navIds); apps.set(appName, navIds); - } + }; + for (const app of recordsOf(stack.apps)) collectAppRecord(app); + // [#19349] …and the apps an entry of `packages[]` declares. + // + // ⚠️ This is the half #18442 did NOT cover, and the distinction is the whole + // point: that change reads `navigationContributions`, so an app became + // addressable only where THIS package contributes into it. A sibling's + // `apps[]` DECLARATION was invisible either way, which broke both halves — + // with no contribution the app NAME was the orphan, and with one the name + // resolved while the owner's own navigation ids were orphans. + // + // ⛔ Names alone would close only the first half and would then report every + // id under a resolved app as an orphan, so the RECORD is folded and walked by + // the same `walkNav`. Nav ids accumulate into one Set per app — the merge the + // declaration loop already performs for two same-named entries — because at + // runtime `apps` is a `concat` collection and every entry's items arrive. + // + // ⚠️ Order is load-bearing: this runs BEFORE the contributed-only pass below, + // so an app a sibling declares is a DECLARED app here and never acquires the + // `contributedOnly` diagnosis, whose remedy would send the author to a + // package sitting in the same artifact. + for (const app of artifactProvidedRecords(stack, 'apps')) collectAppRecord(app); // [#18442] …and every app this stack CONTRIBUTES into but does not declare. // @@ -1142,10 +1247,21 @@ function buildUniverse(stack: AnyRec): Universe { } // ── Dashboards: widget ids + header action urls ── + // + // …from the stack in hand and from the artifact's other entries (#19349). + // ⛔ Names alone would resolve `dashboards.` and then judge its widget + // ids and header `actionUrl`s against an EMPTY set — the trap #19064 recorded + // one collection over, moved one rung down — so the RECORD is folded and the + // sub-rung derived from it exactly as for a declaration in hand. const dashboards = new Map; actions: Set }>(); - for (const dash of recordsOf(stack.dashboards)) { + // `ownDeclaration`: where both declare the same dashboard name, the record + // this leg is JUDGING keeps the slot (#19064's decision). ⛔ Not a union of + // the two id sets — that would make a sibling's widget ids addressable under + // a dashboard this package defines, which is a false negative. + const collectDashboardRecord = (dash: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => { const dashName = strName(dash.name); - if (!dashName) continue; + if (!dashName) return; + if (!ownDeclaration && dashboards.has(dashName)) return; const widgets = new Set(); for (const widget of recordsOf(dash.widgets)) { const id = strName(widget.id) ?? strName(widget.name); @@ -1161,6 +1277,10 @@ function buildUniverse(stack: AnyRec): Universe { if (key) actions.add(key); } dashboards.set(dashName, { widgets, actions }); + }; + for (const dash of recordsOf(stack.dashboards)) collectDashboardRecord(dash, { ownDeclaration: true }); + for (const dash of artifactProvidedRecords(stack, 'dashboards')) { + collectDashboardRecord(dash, { ownDeclaration: false }); } // ── Flows: screen node ids + the field names each screen declares ── @@ -1173,10 +1293,20 @@ function buildUniverse(stack: AnyRec): Universe { // every nested screen out of the universe and report each of its keys as an // orphan — a false positive that now FAILS the run, which is exactly the // over-stating ADR-0072 D1 forbids, at the cost the gating severity sets. + // + // …from the stack in hand and from the artifact's other entries (#19349). + // ⛔ Names alone would resolve `flows.` and then report every screen + // node id — and every `config.fields[].name` under one — as an orphan, the + // same trap the dashboard rung records. And the record is what carries the + // `otherNodes` reading too, which is a DIAGNOSIS rather than a resolution: a + // name-only fold would lose the "declares it as a `` node, not a + // `screen`" message and fall back to the vaguer one. const flows = new Map(); - for (const flow of recordsOf(stack.flows)) { + // `ownDeclaration`: the declaration this leg is JUDGING keeps the slot. + const collectFlowRecord = (flow: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => { const flowName = strName(flow.name); - if (!flowName) continue; + if (!flowName) return; + if (!ownDeclaration && flows.has(flowName)) return; const screens = new Map(); const otherNodes = new Map(); for (const { node } of walkFlowNodes(flow, '')) { @@ -1196,6 +1326,10 @@ function buildUniverse(stack: AnyRec): Universe { screens.set(nodeId, { fields, objectName: strName(config?.objectName) }); } flows.set(flowName, { screens, otherNodes }); + }; + for (const flow of recordsOf(stack.flows)) collectFlowRecord(flow, { ownDeclaration: true }); + for (const flow of artifactProvidedRecords(stack, 'flows')) { + collectFlowRecord(flow, { ownDeclaration: false }); } return { objects, extended, apps, contributedOnlyApps, dashboards, flows, globalActions, actionOwners };