From b29ab94bec1e620f60ee2be13e375ed235e97768 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 10:57:53 +0000 Subject: [PATCH 1/2] fix(lint): `translation-target-unknown` reads a view container's default `form.sections` (#5415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `collectViewRecord` derived an object's legal `_sections` names from `fieldGroups[].key`, the named sections on `listViews.*` / `formViews.*`, a page's named `record:details` sections, and the view record's own `sections`. The view CONTAINER's default `form` — what `defineView({ list, form })` declares and `ObjectForm` renders when no named form view is asked for — was in none of those, so `view.form.sections[].name` contributed nothing: a bundle correctly translating a heading that DOES render was reported as keyed to a section "nothing declares", with a hint advising the author to delete it. The default form now feeds the same section collector as `formViews.*`, bound by `bindingOf(view.form) ?? listBinding` — the resolution the CLI i18n walker performs on `view.form.data.object` — so the rule that demands a key and the rule that accepts one agree on which object a heading belongs to. Every anchor is now a call into one collector instead of its own copy of the loop. Tests import the real showcase contact metadata (`Contact` +`ContactViews`): its object declares `field.group` and no `fieldGroups[]`, so the default form is its ONLY section anchor, which is exactly what a hand-reduced fixture could not have pinned. Over-widening controls kept: an unnamed section is still untranslatable, an undeclared key is still reported, and the default form is bound by its own `data`, not by the list beside it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- ...on-refs-container-default-form-sections.md | 36 +++++ .../validate-translation-references.test.ts | 134 ++++++++++++++++++ .../src/validate-translation-references.ts | 56 +++++--- 3 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 .changeset/translation-refs-container-default-form-sections.md diff --git a/.changeset/translation-refs-container-default-form-sections.md b/.changeset/translation-refs-container-default-form-sections.md new file mode 100644 index 0000000000..bd951169e8 --- /dev/null +++ b/.changeset/translation-refs-container-default-form-sections.md @@ -0,0 +1,36 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `translation-target-unknown` reads a view container's DEFAULT `form.sections` (#5415) + +`validate-translation-references` derives the `_sections` names an object may +legally be translated by from a list of anchors: `fieldGroups[].key`, the named +sections on `listViews.*` / `formViews.*`, the named sections on a page's +`record:details` component, and the view record's own `sections`. The list was +missing one: the view CONTAINER's **default form** — the `form` that +`defineView({ list, form, formViews })` declares and that `ObjectForm` renders +when no named form view is asked for. + +`collectViewRecord` iterated `['listViews', 'formViews']`, and `view.form` is +neither of those nor the record's own `sections`, so `view.form.sections[].name` +contributed **nothing** to the fact set. The renderer resolves those headings +through exactly the same `sectionLabel(object, section.name, …)` convention as +any named form view, so a bundle that correctly translated one of them was +reported as keyed to a section "which nothing on object X declares", with a hint +advising the author to delete a translation that renders. On the in-repo +showcase contact surface — whose object declares `field.group` and no +`fieldGroups[]`, so the default form is its **only** section anchor — all four +headings were in that state, and the hint went as far as "declares no named +section at all". + +The default form now feeds the same section collector as `formViews.*`, bound +by `bindingOf(view.form) ?? listBinding` — i.e. `form.data.object` first, then +the record-level object, then the list beside it — which is the resolution the +CLI i18n walker performs for the same surface, so the rule that DEMANDS a key +and the rule that ACCEPTS one agree on which object a heading belongs to. Each +anchor is now a call into one collector rather than its own copy of the loop. + +Nothing tightens: an unnamed section is still untranslatable (it has no stable +key to look up), and a `_sections` key no anchor declares is still reported — +now with the real anchors enumerated in the hint. diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 6e0412aa4b..409242a972 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -6,6 +6,10 @@ import { TRANSLATION_TARGET_UNKNOWN, TRANSLATION_OPTION_KEY_UNKNOWN, } from './validate-translation-references.js'; +// Real shipped metadata — see the `#5415` describe block for why this is +// imported rather than reduced by hand. +import { Contact } from '../../../examples/app-showcase/src/data/objects/contact.object.js'; +import { ContactViews } from '../../../examples/app-showcase/src/ui/views/contact.view.js'; /** A stack shaped like the HotCRM lead surface: fields, options, a view, an action. */ const leadStack = (translations: unknown[]) => ({ @@ -532,4 +536,134 @@ describe('validateTranslationReferences — section anchors', () => { expect(findings).toHaveLength(1); expect(findings[0].hint).toContain('declares no named section at all'); }); + + // #5415. `collectViewRecord` walked `['listViews', 'formViews']` and the + // record's own `sections`; the CONTAINER's default form — `defineView({ form: + // … })`, the one `ObjectForm` renders when no named form view is asked for — + // was in neither, so its named sections contributed nothing and a correct + // translation of a heading that DOES render was reported as an unknown + // target. + it('resolves a section named on the container default `form`', () => { + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } }, + form: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [{ name: 'contact_info', label: 'Contact Info' }], + }, + }, + ], + translations: [ + { en: { objects: { crm_lead: { label: 'Lead', _sections: { contact_info: { label: 'Contact' } } } } } }, + ], + }; + expect(validateTranslationReferences(stack)).toEqual([]); + }); + + it('binds the default `form` by its OWN data, not by the list beside it', () => { + // Two objects in one record: the list shows leads, the form edits contacts. + // The section belongs to whatever `form.data.object` says — the same + // resolution the CLI i18n walker's `viewObjectName` performs. + // + // Both directions are asserted in ONE stack on purpose. "crm_lead is still + // reported" alone would pass just as well if the default form contributed + // NOTHING (the pre-#5415 behaviour) — it is the `crm_contact` half, which + // resolves only once the form is collected under its own binding, that + // makes the pair falsifiable. + const stack = (objectName: string) => ({ + objects: [ + { name: 'crm_lead', fields: { name: { type: 'text' } } }, + { name: 'crm_contact', fields: { name: { type: 'text' } } }, + ], + views: [ + { + list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } }, + form: { + type: 'simple', + data: { provider: 'object', object: 'crm_contact' }, + sections: [{ name: 'contact_info', label: 'Contact Info' }], + }, + }, + ], + translations: [ + { en: { objects: { [objectName]: { label: 'X', _sections: { contact_info: { label: 'Contact' } } } } } }, + ], + }); + + expect(validateTranslationReferences(stack('crm_contact'))).toEqual([]); + + const findings = validateTranslationReferences(stack('crm_lead')); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._sections.contact_info'); + }); +}); + +/** + * #5415, pinned against the metadata the repo actually ships. + * + * `examples/app-showcase` is imported here rather than reduced by hand on + * purpose: the defect was an anchor MISSING from a list, and a hand-written + * fixture can only pin the anchors whoever wrote it remembered. The showcase + * contact surface is the exact shape that exposed it — + * + * - the object declares `field.group` and NO `fieldGroups[]`, so the object + * side contributes no section anchor at all; + * - the container's default `form` names all four sections; + * - `formViews.create` names none of its own (a sparse create override with + * one unnamed section) — which is what keeps the "still reports a real + * unknown" control below honest. + * + * So every `_sections` key this surface legitimately carries comes from + * `view.form.sections[].name`, and nothing else. + */ +describe('validateTranslationReferences — the showcase contact surface (#5415)', () => { + const showcaseContactStack = (translations: unknown[]) => ({ + objects: [Contact], + views: [ContactViews], + translations, + }); + + const sectionBundle = (sections: Record) => [ + { 'zh-CN': { objects: { showcase_contact: { _sections: sections } } } }, + ]; + + it('accepts every section the default form names', () => { + // `ObjectForm` renders these four headings and resolves each through + // `sectionLabel(object, section.name, …)` — translating them is correct. + const findings = validateTranslationReferences( + showcaseContactStack( + sectionBundle({ + contact: { label: '联系方式' }, + work: { label: '工作' }, + status: { label: '状态' }, + notes: { label: '备注' }, + }), + ), + ); + expect(findings).toEqual([]); + }, 60_000); + + it('still reports a section name nothing declares, and names the real ones', () => { + // The over-widening control: `contract` is a typo of `contact`, and + // `who_is_this` is the LABEL of `formViews.create`'s unnamed section — an + // unnamed section is not translatable, so neither key may resolve. + const findings = validateTranslationReferences( + showcaseContactStack(sectionBundle({ contract: { label: '合同' }, who_is_this: { label: '这是谁' } })), + ); + expect(findings).toHaveLength(2); + expect(findings.map((f) => f.rule)).toEqual([TRANSLATION_TARGET_UNKNOWN, TRANSLATION_TARGET_UNKNOWN]); + expect(findings.map((f) => f.path)).toEqual([ + 'translations[0]["zh-CN"].objects.showcase_contact._sections.contract', + 'translations[0]["zh-CN"].objects.showcase_contact._sections.who_is_this', + ]); + // The hint now enumerates the anchors the object really has, instead of + // claiming it "declares no named section at all". + for (const finding of findings) { + expect(finding.hint).toContain('Declared sections: contact, notes, status, work'); + expect(finding.hint).not.toContain('declares no named section at all'); + } + }, 60_000); }); diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 475bb405f1..c15484822b 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -218,6 +218,18 @@ function emptyFacts(): ObjectFacts { * at the record root. A record-level lookup alone resolves to nothing on * the canonical shape, which silently drops the whole record — a rule that * then reports every view key the app ships. + * + * A third thing was learned later, from the showcase (#5415): the container's + * DEFAULT form (`form`) is a section anchor too. It is not one of the + * `formViews.*` entries and it is not the record's own `sections` either, so + * for two protocol versions its named sections contributed NOTHING — and a + * bundle that correctly translated one of them was reported as keyed to a + * section "nothing declares", with a hint advising the author to delete a + * translation that renders. `ObjectForm` reads that form and resolves its + * headings through the same `sectionLabel(object, section.name, …)` convention + * as any named form view, so every anchor below feeds ONE collector: the list + * of anchors is now a list of call sites, not four copies of a loop that can + * drift apart one at a time. */ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => ObjectFacts): void { const recordObject = viewObjectName(view); @@ -228,6 +240,22 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec if (objectName && name) factsFor(objectName).views.add(name); }; + /** + * Register the `_sections` names one form-ish container declares. + * + * Form sections carry an OPTIONAL `name` that exists purely for the + * `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for i18n + * lookup"). A section without one cannot be translated at all, so it + * contributes nothing here. + */ + const addSections = (container: AnyRec, binding: string | undefined) => { + if (!binding) return; + for (const section of asArray(container.sections)) { + const sectionName = strName(section.name); + if (sectionName) factsFor(binding).sections.add(sectionName); + } + }; + const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined; if (isRec(view.list)) addView(listBinding, strName(view.list.name)); addView(recordObject ?? listBinding, strName(view.name)); @@ -240,27 +268,19 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec const binding = bindingOf(sub) ?? listBinding; addView(binding, subKey); addView(binding, strName(sub.name)); - - // Form sections carry an OPTIONAL `name` that exists purely for the - // `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for - // i18n lookup"). A section without one cannot be translated at all, so - // it contributes nothing here. - if (binding) { - for (const section of asArray(sub.sections)) { - const sectionName = strName(section.name); - if (sectionName) factsFor(binding).sections.add(sectionName); - } - } + addSections(sub, binding); } } - const sectionBinding = recordObject ?? listBinding; - if (sectionBinding) { - for (const section of asArray(view.sections)) { - const sectionName = strName(section.name); - if (sectionName) factsFor(sectionBinding).sections.add(sectionName); - } - } + // The container's default form — the one `defineView({ form: … })` declares + // and `ObjectForm` renders when no named form view is asked for. Bound the + // way the CLI i18n walker's `viewObjectName` resolves `view.form.data.object` + // (#5415), so the two agree on which object the headings belong to. + // Deliberately sections only: the default form has no map key, and whether it + // contributes a `_views` name is the neighbouring question #5164 owns. + if (isRec(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding); + + addSections(view, recordObject ?? listBinding); } /** The object a view (or one of its containers) binds to, across the shapes it is authored in. */ From 71305f5e6a9eadd56f1df9106503d99469be9199 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:03:13 +0000 Subject: [PATCH 2/2] docs(lint): drop an unverified version claim from the collector comment Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- packages/lint/src/validate-translation-references.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index c15484822b..c831e120eb 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -222,7 +222,7 @@ function emptyFacts(): ObjectFacts { * A third thing was learned later, from the showcase (#5415): the container's * DEFAULT form (`form`) is a section anchor too. It is not one of the * `formViews.*` entries and it is not the record's own `sections` either, so - * for two protocol versions its named sections contributed NOTHING — and a + * its named sections contributed NOTHING to the fact set — and a * bundle that correctly translated one of them was reported as keyed to a * section "nothing declares", with a hint advising the author to delete a * translation that renders. `ObjectForm` reads that form and resolves its