From ffc92738fef91b9dd59b7eb7d73a0f120c7644ff Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:35:12 +0000 Subject: [PATCH 1/3] test(lint): pin the translation rule's remaining collection rungs against a sibling package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test-first, red before the fix: 18 failed | 114 passed. Every one of the six collection rungs of `translation-target-unknown` builds its universe from the top-level collection alone, so on `os build`'s per-package leg a package translating what a SIBLING package of the same artifact declares is reported as an orphan at `error`. Each rung is pinned in both directions in one describe block — the false orphan that must disappear, and the true orphan that must remain — plus the CONTROL leg (the same bundle judged alone) so "no findings" cannot be confused with the rung going quiet. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../validate-translation-references.test.ts | 453 ++++++++++++++++++ 1 file changed, 453 insertions(+) diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index a163819b5e2..840467d1e1e 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -1416,6 +1416,459 @@ 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: { main: [{ 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 From 3be64ce17060617b47c7f1cd92110902dbc56417 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:38:01 +0000 Subject: [PATCH 2/3] fix(lint): the translation rule's remaining collection rungs read what the artifact's packages[] provide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `translation-target-unknown` built the universe for `views`, `pages`, `actions`, `apps`, `dashboards` and `flows` from the top-level collection alone, while the same file already read `packages[].manifest.…` for `navigationContributions`, `objectExtensions` and `objects`. So on `os build`'s per-package leg a package translating what a SIBLING package of the same artifact declares was told the target does not exist, at `error`, with a remedy that deletes a translation the runtime honours. All six fold RECORDS, for three distinct reasons: `dashboards` / `flows` / `apps` carry a sub-rung derived from the record; the `actions` record is read downstream by `checkActionParams` and carries the owner that keeps a bound action out of `globalActions`; `views` and `pages` have no bundle rung of their own at all and contribute facts under the object they bind to. `apps` is the half #18442 did not cover: that change reads contributions, so a sibling's declaration was invisible whether or not this package contributed into the app. Folding the records before the contributed-only pass also stops such an app acquiring a diagnosis that points at a package in this artifact. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- .../validate-translation-references.test.ts | 4 +- .../src/validate-translation-references.ts | 158 +++++++++++++++--- 2 files changed, 139 insertions(+), 23 deletions(-) diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 840467d1e1e..b03e448e85a 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -1716,7 +1716,9 @@ describe('validateTranslationReferences — collection rungs a SIBLING package o { name: 'order_detail', object: 'crm_order', - regions: { main: [{ type: 'record:details', properties: { sections: [{ name: 'billing' }] } }] }, + regions: [ + { components: [{ type: 'record:details', properties: { sections: [{ name: 'billing' }] } }] }, + ], }, ], }; diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 21d35a8b8de..324d972e2a4 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -834,9 +834,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 +896,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 +996,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 +1046,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 +1089,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 +1152,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 +1167,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 +1227,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 +1257,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 +1273,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 +1306,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 }; From 1ef60553c7f227254a30ad09184d494d6c414254 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 01:40:22 +0000 Subject: [PATCH 3/3] docs(lint): record the artifact reach in the rule header, and the changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file header documented the `packages[]` reach for the object ladder only, which left the implementation reaching wider than the declaration — the same mismatch, in the opposite direction, that this rule's object rung was carded for. One paragraph now names the single carrier reader, the disposition table that decides which collections may be asked of it, and why `apps` reads both that carrier and `navigationContributions`. Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude --- ...lation-target-collection-artifact-reach.md | 17 ++++++++++++++++ .../src/validate-translation-references.ts | 20 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 .changeset/19349-translation-target-collection-artifact-reach.md 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.ts b/packages/lint/src/validate-translation-references.ts index 324d972e2a4..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