diff --git a/.changeset/lint-translatable-sections.md b/.changeset/lint-translatable-sections.md new file mode 100644 index 0000000000..7c1be300d4 --- /dev/null +++ b/.changeset/lint-translatable-sections.md @@ -0,0 +1,47 @@ +--- +'@objectstack/lint': patch +--- + +lint: warn when a form section declares a `label` but no `name` — the heading no translation key can ever address + +`_sections` is keyed by the section's `name`, and every renderer that draws a +section heading resolves it that way (`sectionLabel(objectName, section.name, +authored)` — `plugin-form`'s `ObjectForm`/`ModalForm`, `plugin-detail`'s +`record:details`), falling back to the authored label when there is no name. +So a section authored with a `label` and no `name` is untranslatable **by +construction**, and every gate we own was structurally blind to it: + +- the reference validator reports keys a bundle carries that nothing declares — + a nameless section produces no key, so there is no orphan to report; +- the i18n coverage walk (#5405) emits one expected key per `sections[].name` — + a section with no name contributes nothing to demand, so the report reads + 100% while the heading renders in the source locale in every locale. + +Measured on HotCRM: **70 of 70** form-view sections across all 14 view files are +in exactly that state, with four locales at full declared coverage and zero +warnings anywhere. It is also the real cause of the reported `Case / SLA / +Resolution` English strip — that object's *detail page* sections carry names and +translate, while its *form view* sections carry none. + +`validateTranslatableSections` (rule id `translation-section-name-missing`) joins +the reference-integrity suite, so it runs on `os validate`, `os lint` and +`os compile` at once. It reads exactly the anchors the two landed halves already +agree on: a view container's `sections`, its **default** `form.sections`, every +`listViews.*` / `formViews.*` sub-container, the same three on views embedded in +an object, and `record:details` sections nested anywhere in a page's component +tree. `fieldGroups`-derived sections are out of range by construction — their +heading is keyed by `fieldGroups[].key`, so they always have a name. + +**Warning, and opt-in.** Nothing crashes and nothing is dead — one heading stays +in the source locale — so the severity matches its sibling rules (ADR-0072 D1) +and nothing that passed before starts failing. `os validate` over +`examples/app-showcase` now reports 14 of these (6 from form views, 8 from +`record:details` pages) and still exits 0. A section warns only when the +object it renders under carries some translation of its own, which keeps the +monolingual case silent exactly as the coverage gate already does. + +The fix is a diagnostic at the **producer**, deliberately not tolerance at the +consumer: deriving a lookup key by slugifying the label would fossilize a second +de-facto contract next to the declared one, and would move the day anyone edits +the heading text. The `name` the hint suggests is a suggestion for the author to +write down, never a key anything resolves. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index bc884b57c2..d01a0be739 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -311,6 +311,18 @@ export type { TranslationRefSeverity, } from './validate-translation-references.js'; +// The other end of the same question (#5417): a form section authored with a +// `label` and no `name` renders a heading `_sections` can never address — no +// orphan key to report, and nothing for the coverage walk to demand. +export { + validateTranslatableSections, + TRANSLATION_SECTION_NAME_MISSING, +} from './validate-translatable-sections.js'; +export type { + TranslatableSectionFinding, + TranslatableSectionSeverity, +} from './validate-translatable-sections.js'; + export { validateAiSurfaceAffinity, AI_SKILL_SURFACE_MISMATCH, diff --git a/packages/lint/src/reference-integrity-suite.test.ts b/packages/lint/src/reference-integrity-suite.test.ts index 5e79bbe482..3cc1fea44d 100644 --- a/packages/lint/src/reference-integrity-suite.test.ts +++ b/packages/lint/src/reference-integrity-suite.test.ts @@ -23,6 +23,7 @@ describe('reference-integrity suite — membership', () => { 'validateNavAccess', 'validateNavTargetRefs', 'validateTranslationReferences', + 'validateTranslatableSections', 'validateFlowTemplatePaths', 'validateAiSurfaceAffinity', 'validateAiToolReferences', @@ -94,6 +95,15 @@ describe('reference-integrity suite — every member actually runs', () => { // validateActionNameRefs: no such action. bulkActions: ['mass_update'], }, + // validateTranslatableSections (#5417): a form section authored with a + // `label` and no `name`. `_sections` is keyed by name, so this heading + // has no key any bundle can carry — and crm_lead IS translated below, + // which is what opens the rule's opt-in gate. + form: { + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + sections: [{ label: 'Qualification', columns: 2, fields: ['name'] }], + }, }, ], pages: [ @@ -228,6 +238,7 @@ describe('reference-integrity suite — every member actually runs', () => { expect(rules).toContain('chart-measure-unknown'); expect(rules).toContain('nav-object-ungranted'); expect(rules).toContain('translation-target-unknown'); + expect(rules).toContain('translation-section-name-missing'); expect(rules).toContain('flow-template-unknown-field'); expect(rules).toContain('ai-skill-surface-mismatch'); expect(rules).toContain('ai-skill-tool-unresolved'); diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 7fb13532ce..6c1c90005d 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -62,6 +62,7 @@ import { validateChartBindings } from './validate-chart-bindings.js'; import { validateNavAccess } from './validate-nav-access.js'; import { validateNavTargetRefs } from './validate-nav-target-refs.js'; import { validateTranslationReferences } from './validate-translation-references.js'; +import { validateTranslatableSections } from './validate-translatable-sections.js'; import { validateFlowTemplatePaths } from './validate-flow-template-paths.js'; import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js'; import { validateAiToolReferences } from './validate-ai-tool-references.js'; @@ -120,6 +121,14 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // `component` (an unregistered ref renders a named diagnostic, not silence). { name: 'validateNavTargetRefs', run: validateNavTargetRefs }, { name: 'validateTranslationReferences', run: validateTranslationReferences }, + // The same family from the other end (#5417). Its sibling above asks "does + // this bundle key resolve?"; this one asks "is there a key at all?" — a form + // section authored with a `label` and no `name` renders a heading that + // `_sections` (keyed by name) can never address, so neither the orphan check + // nor the coverage walk can see it. A reference that cannot be written is + // still a reference question, and warning-only for the same reason its + // sibling is: one heading stays in the source locale, nothing breaks. + { name: 'validateTranslatableSections', run: validateTranslatableSections }, { name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths }, { name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity }, { name: 'validateAiToolReferences', run: validateAiToolReferences }, diff --git a/packages/lint/src/validate-translatable-sections.test.ts b/packages/lint/src/validate-translatable-sections.test.ts new file mode 100644 index 0000000000..69d7050b08 --- /dev/null +++ b/packages/lint/src/validate-translatable-sections.test.ts @@ -0,0 +1,459 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateTranslatableSections, + TRANSLATION_SECTION_NAME_MISSING, +} from './validate-translatable-sections.js'; +import { validateTranslationReferences } from './validate-translation-references.js'; +import { Contact } from '../../../examples/app-showcase/src/data/objects/contact.object.js'; +import { ContactViews } from '../../../examples/app-showcase/src/ui/views/contact.view.js'; +import { TaskViews } from '../../../examples/app-showcase/src/ui/views/task.view.js'; +import { ShowcaseTranslationBundle } from '../../../examples/app-showcase/src/system/translations/index.js'; + +/** A `crm_case` object with the two fields the fixtures below lay out. */ +const crmCase = { + name: 'crm_case', + label: 'Case', + fields: [ + { name: 'subject', type: 'text', label: 'Subject' }, + { name: 'status', type: 'select', label: 'Status', options: [{ value: 'open', label: 'Open' }] }, + ], +}; + +/** A bundle that translates `crm_case` — the opt-in signal the rule gates on. */ +const caseTranslated = [ + { 'zh-CN': { objects: { crm_case: { label: '个案', fields: { subject: { label: '主题' } } } } } }, +]; + +const data = { provider: 'object', object: 'crm_case' }; + +/** The three nameless headings #5417 measured on HotCRM's `crm_case` form view. */ +const hotcrmSections = [ + { label: 'Case', columns: 2, fields: ['subject'] }, + { label: 'SLA', columns: 2, fields: ['status'] }, + { label: 'Resolution', columns: 1, fields: ['status'] }, +]; + +describe('validateTranslatableSections — the HotCRM shape (#5417)', () => { + /** + * The acceptance anchor from the issue: `crm_case`'s form view sections are + * authored `{ label: 'Case' } / { label: 'SLA' } / { label: 'Resolution' }` + * with no `name`, while its detail page's sections carry names and translate. + * That difference is the reported `Case / SLA / Resolution` English strip + * (#5408) — and until this rule, nothing said a word about it. + */ + const stack = { + objects: [crmCase], + views: [ + { + name: 'case_views', + list: { name: 'all', type: 'grid', data }, + formViews: { edit: { type: 'simple', data, sections: hotcrmSections } }, + }, + ], + translations: caseTranslated, + }; + + it('warns once per nameless labelled section, in authoring order', () => { + const findings = validateTranslatableSections(stack); + expect(findings.map((f) => f.path)).toEqual([ + 'views[0].formViews.edit.sections[0]', + 'views[0].formViews.edit.sections[1]', + 'views[0].formViews.edit.sections[2]', + ]); + expect(findings.map((f) => f.rule)).toEqual([ + TRANSLATION_SECTION_NAME_MISSING, + TRANSLATION_SECTION_NAME_MISSING, + TRANSLATION_SECTION_NAME_MISSING, + ]); + }); + + it('is advisory — one heading stays English, nothing breaks', () => { + for (const finding of validateTranslatableSections(stack)) { + expect(finding.severity).toBe('warning'); + } + }); + + it('names the heading, the object, and the key that can never exist', () => { + const [first] = validateTranslatableSections(stack); + expect(first.where).toBe('object "crm_case" · view "case_views" · formViews.edit · section "Case"'); + expect(first.message).toContain('Section "Case" declares a label but no `name`'); + expect(first.message).toContain('objects.crm_case._sections..label'); + // The coverage half of the invisibility, said out loud: #5405's walker is + // keyed on `sections[].name`, so it cannot report this as missing either. + expect(first.message).toContain('walks `sections[].name`'); + }); + + it('suggests a snake_case name derived from the heading — as a hint, never a key', () => { + const findings = validateTranslatableSections(stack); + expect(findings[0].hint).toContain("`name: 'case'`"); + expect(findings[0].hint).toContain('objects.crm_case._sections.case.label'); + expect(findings[2].hint).toContain("`name: 'resolution'`"); + // The renderer resolves by name only. Saying so in the hint is what keeps an + // author (or an AI author) from assuming the label text is itself a key — + // the slug-fishing fallback objectui#3373 pinned as WRONG. + expect(findings[0].hint).toContain('never a key derived from the label'); + }); + + it('says nothing once the sections carry names', () => { + const named = { + ...stack, + views: [ + { + name: 'case_views', + list: { name: 'all', type: 'grid', data }, + formViews: { + edit: { + type: 'simple', + data, + sections: hotcrmSections.map((s, i) => ({ ...s, name: ['case', 'sla', 'resolution'][i] })), + }, + }, + }, + ], + }; + expect(validateTranslatableSections(named)).toEqual([]); + }); +}); + +describe('validateTranslatableSections — the opt-in gate', () => { + const nameless = { + objects: [crmCase], + views: [{ name: 'case_views', form: { type: 'simple', data, sections: hotcrmSections } }], + }; + + it('says nothing on a monolingual stack (no bundles at all)', () => { + // Same invariant `computeI18nCoverage` holds: declare no locales and no + // bundles and the gate reports nothing rather than inventing a debt. + expect(validateTranslatableSections(nameless)).toEqual([]); + }); + + it('says nothing when the stack translates OTHER objects but not this one', () => { + const findings = validateTranslatableSections({ + ...nameless, + translations: [{ 'zh-CN': { objects: { crm_lead: { label: '线索' } } } }], + }); + expect(findings).toEqual([]); + }); + + it('warns as soon as the object itself carries any translation', () => { + const findings = validateTranslatableSections({ ...nameless, translations: caseTranslated }); + expect(findings).toHaveLength(3); + }); + + it('counts a bundle node with only a `_sections` entry as translated', () => { + // The author has already started translating this object's headings — the + // nameless ones next to them are exactly the gap worth naming. + const findings = validateTranslatableSections({ + ...nameless, + translations: [{ 'zh-CN': { objects: { crm_case: { _sections: { intro: { label: '简介' } } } } } }], + }); + expect(findings).toHaveLength(3); + }); +}); + +describe('validateTranslatableSections — the section face', () => { + /** + * The anchors this rule covers must be exactly the anchors the two landed + * halves read: the coverage walker's `section` kind (#5416) and this + * package's `_sections` fact set (#5422). Each case below is asserted TWICE — + * once nameless (this rule warns) and once named + translated (the sibling + * rule accepts the key). An anchor missing from either list fails one half. + */ + const bothWays = (build: (sections: unknown[]) => Record, sectionName: string) => { + const nameless = { ...build([{ label: 'Heading', fields: ['subject'] }]), translations: caseTranslated }; + const named = { + ...build([{ name: sectionName, label: 'Heading', fields: ['subject'] }]), + translations: [ + { 'zh-CN': { objects: { crm_case: { _sections: { [sectionName]: { label: '标题' } } } } } }, + ], + }; + return { nameless, named }; + }; + + const anchors: Array<[string, (sections: unknown[]) => Record, string]> = [ + [ + "a container's default `form` (#5415)", + (sections) => ({ objects: [crmCase], views: [{ name: 'v', form: { type: 'simple', data, sections } }] }), + 'form_section', + ], + [ + 'a named `formViews.*` entry', + (sections) => ({ + objects: [crmCase], + views: [{ name: 'v', list: { name: 'all', data }, formViews: { create: { type: 'simple', sections } } }], + }), + 'create_section', + ], + [ + 'a `listViews.*` entry', + (sections) => ({ + objects: [crmCase], + views: [{ name: 'v', list: { name: 'all', data }, listViews: { mine: { sections } } }], + }), + 'list_section', + ], + [ + "the view record's own `sections`", + (sections) => ({ objects: [crmCase], views: [{ name: 'v', object: 'crm_case', sections }] }), + 'record_section', + ], + [ + 'a view embedded on the object', + (sections) => ({ + objects: [{ ...crmCase, views: [{ name: 'inner', form: { type: 'simple', sections } }] }], + }), + 'inner_section', + ], + [ + "the object's own `listViews` container", + (sections) => ({ objects: [{ ...crmCase, listViews: { compact: { sections } } }] }), + 'compact_section', + ], + [ + "a page's `record:details` (#5416's page half)", + (sections) => ({ + objects: [crmCase], + pages: [ + { + name: 'case_detail', + object: 'crm_case', + regions: [{ components: [{ type: 'record:details', properties: { sections } }] }], + }, + ], + }), + 'details_section', + ], + [ + 'a `record:details` nested inside `page:tabs`', + (sections) => ({ + objects: [crmCase], + pages: [ + { + name: 'case_detail', + object: 'crm_case', + regions: [ + { + components: [ + { + type: 'page:tabs', + properties: { + items: [ + { children: [{ type: 'record:details', properties: { sections } }] }, + ], + }, + }, + ], + }, + ], + }, + ], + }), + 'tabbed_section', + ], + ]; + + for (const [label, build, sectionName] of anchors) { + it(`covers ${label}`, () => { + const { nameless, named } = bothWays(build, sectionName); + expect(validateTranslatableSections(nameless)).toHaveLength(1); + expect(validateTranslatableSections(nameless)[0].message).toContain('objects.crm_case._sections'); + // The co-derivation half: the same anchor, NAMED, is a key the reference + // validator accepts. If either list drifts, exactly one of these fails. + expect(validateTranslatableSections(named)).toEqual([]); + expect(validateTranslationReferences(named)).toEqual([]); + }); + } +}); + +describe('validateTranslatableSections — what it deliberately leaves alone', () => { + it('ignores `fieldGroups`-derived sections (their key IS the name)', () => { + const findings = validateTranslatableSections({ + objects: [ + { + ...crmCase, + fieldGroups: [{ key: 'basics', label: 'Basics' }], + fields: [ + { name: 'subject', type: 'text', label: 'Subject', group: 'basics' }, + { name: 'status', type: 'select', label: 'Status', group: 'basics' }, + ], + }, + ], + translations: caseTranslated, + }); + expect(findings).toEqual([]); + }); + + it('ignores a name-keyed `sections` MAP (the key supplies the name)', () => { + const findings = validateTranslatableSections({ + objects: [crmCase], + views: [{ name: 'v', form: { type: 'simple', data, sections: { intro: { label: 'Intro' } } } }], + translations: caseTranslated, + }); + expect(findings).toEqual([]); + }); + + it('ignores a section with no heading at all — that is `required/label`\'s question', () => { + const findings = validateTranslatableSections({ + objects: [crmCase], + views: [{ name: 'v', form: { type: 'simple', data, sections: [{ fields: ['subject'] }] } }], + translations: caseTranslated, + }); + expect(findings).toEqual([]); + }); + + it('ignores a container bound to no object at all', () => { + // Nothing resolves `_sections` without an object, so there is no key to + // describe — and guessing an owner is how a rule reports the wrong one. + const findings = validateTranslatableSections({ + objects: [crmCase], + views: [{ name: 'v', form: { type: 'simple', sections: hotcrmSections } }], + translations: caseTranslated, + }); + expect(findings).toEqual([]); + }); + + it('reads a detail section\'s `title` as its heading', () => { + // `record:details` reads `title ?? label` — a nameless section with only a + // `title` renders a heading just the same. + const findings = validateTranslatableSections({ + objects: [crmCase], + pages: [ + { + name: 'case_detail', + object: 'crm_case', + regions: [{ components: [{ type: 'record:details', properties: { sections: [{ title: 'SLA' }] } }] }], + }, + ], + translations: caseTranslated, + }); + expect(findings).toHaveLength(1); + expect(findings[0].where).toContain('section "SLA"'); + }); + + it('keys a retargeted component under the object it actually binds', () => { + // A `record:details` pointed at another object keys its headings THERE, so + // the gate must consult that object's translations, not the page's. + const page = { + name: 'case_detail', + object: 'crm_lead', + regions: [ + { + components: [ + { + type: 'record:details', + dataSource: { object: 'crm_case' }, + properties: { sections: [{ label: 'SLA' }] }, + }, + ], + }, + ], + }; + const findings = validateTranslatableSections({ + objects: [crmCase, { name: 'crm_lead', fields: [] }], + pages: [page], + translations: caseTranslated, + }); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('objects.crm_case._sections'); + }); + + it('returns nothing for an empty stack', () => { + expect(validateTranslatableSections({})).toEqual([]); + }); +}); + +/** + * #5417 measured against the metadata the repo actually ships. + * + * `examples/app-showcase` is imported rather than reduced by hand for the same + * reason #5415's sibling test does it: the defect is an anchor MISSING from a + * list, and a hand-written fixture can only pin the anchors its author + * remembered. + * + * `TaskViews` is registered in the showcase's `views:` array and its object is + * translated in both `en` and `zh-CN` by the shipped bundle, so its two nameless + * headings are the in-repo counterpart of HotCRM's 70/70 — real metadata, really + * rendering in the source locale on a zh-CN session. Running `os validate` over + * the whole showcase config reports 14 of them (6 from form views, 8 from + * `record:details` pages) and still exits 0: these are `warning` findings and + * only `error` gates, so the showcase stays green and the advisory is the point. + */ +describe('validateTranslatableSections — the shipped showcase task views', () => { + it('reports both nameless headings the shipped task container declares', () => { + const findings = validateTranslatableSections({ + views: [TaskViews], + translations: [ShowcaseTranslationBundle], + }); + expect(findings.map((f) => f.where)).toEqual([ + 'object "showcase_task" · formViews.edit · section "Task"', + 'object "showcase_task" · formViews.quick · section "Quick Edit"', + ]); + for (const finding of findings) { + expect(finding.rule).toBe(TRANSLATION_SECTION_NAME_MISSING); + expect(finding.severity).toBe('warning'); + } + }, 60_000); +}); + +/** + * The other half of the showcase evidence, on the surface #5415 pinned. + * + * `ContactViews` is exported from the views barrel but not listed in the + * showcase's `views:` array, so it is composed here exactly as #5415's sibling + * test composes it — object + container. What makes it worth pinning is the + * CONTRAST it carries in one file: the default `form` names all four of its + * sections (and #5415 pins that the reference validator accepts bundles for + * exactly those four), while the sparse `formViews.create` override names none. + * One rule must therefore be silent about the first and loud about the second, + * or the two rules would tell an author opposite things about one surface. + */ +describe('validateTranslatableSections — the showcase contact surface (#5415)', () => { + const showcaseContactStack = { + objects: [Contact], + views: [ContactViews], + translations: [ShowcaseTranslationBundle], + }; + + it('reports the sparse create override and nothing from the named default form', () => { + const findings = validateTranslatableSections(showcaseContactStack); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_SECTION_NAME_MISSING); + expect(findings[0].path).toBe('views[0].formViews.create.sections[0]'); + expect(findings[0].where).toBe( + 'object "showcase_contact" · formViews.create · section "Who is this?"', + ); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].hint).toContain('objects.showcase_contact._sections.who_is_this.label'); + }, 60_000); + + it('agrees with #5415 about the four sections the default form names', () => { + // Those four ARE addressable, and `validateTranslationReferences` accepts + // bundles for them. This rule must stay silent there. + const paths = validateTranslatableSections(showcaseContactStack).map((f) => f.path); + expect(paths.filter((p) => p.startsWith('views[0].form.sections'))).toEqual([]); + expect( + validateTranslationReferences({ + objects: [Contact], + views: [ContactViews], + translations: [ + { + 'zh-CN': { + objects: { + showcase_contact: { + _sections: { + contact: { label: '联系方式' }, + work: { label: '工作' }, + status: { label: '状态' }, + notes: { label: '备注' }, + }, + }, + }, + }, + }, + ], + }), + ).toEqual([]); + }, 60_000); +}); diff --git a/packages/lint/src/validate-translatable-sections.ts b/packages/lint/src/validate-translatable-sections.ts new file mode 100644 index 0000000000..c65931fcb0 --- /dev/null +++ b/packages/lint/src/validate-translatable-sections.ts @@ -0,0 +1,358 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0072 — reference resolvability] The third leg: a rendered heading that + * NO key can ever address (issue #5417). + * + * `validate-translation-references` asks "does this bundle key point at + * anything?" and the `os lint --i18n` coverage walk asks "does every key the + * metadata expects have a translation?". Both questions are about keys that + * EXIST. A form section authored with a `label` and no `name` exists on neither + * side of that pair: + * + * - the renderer keys the heading off `section.name` — + * `sectionLabel(objectName, section.name, authored)` in `@object-ui/i18n`, + * guarded on the name in every call site (`plugin-form`'s + * `ObjectForm`/`ModalForm`, `plugin-detail`'s `record:details`), so a + * nameless section falls back to the authored label in EVERY locale; + * - `_sections` is keyed by section name, so there is no key for a bundle to + * carry — nothing to report as an orphan; + * - the coverage walker (#5405 / PR #5416) emits `sections[].name`, so a + * section with no name contributes no `ExpectedEntry` and the report reads + * 100% while the heading renders in the source locale. + * + * The heading is therefore untranslatable BY CONSTRUCTION, and every gate we + * own is structurally blind to it. Measured on HotCRM at `0899b4f`: 70 of 70 + * form-view sections, across all 14 view files, in exactly that state — with + * four locales at full declared coverage and zero warnings anywhere. + * + * ── Why the fix is a diagnostic and not renderer tolerance ─────────────── + * + * The tempting "fix" is at the consumer: slugify the label and go fishing for a + * second key. That is precisely the lenient fallback Prime Directive #12 bans — + * it fossilizes a second de-facto contract (`_sections.`) + * alongside the declared one (`_sections.`), and the slug moves the day + * anyone edits the heading text. objectui#3373 pinned the renderer's `name` + * guard as CORRECT for that reason. So the defect is at the producer, and this + * rule is where the producer hears about it. The `name` a hint suggests below + * is a suggestion for the AUTHOR to write down, never a key anything resolves. + * + * ── Severity: warning ──────────────────────────────────────────────────── + * + * Same reading as its sibling rule (ADR-0072 D1): nothing crashes and nothing + * is dead — one heading renders in the source locale. That is weaker than the + * dead references `validate-object-references` reports as errors, and the + * severity should say so. Requiring `name` outright is a SCHEMA change + * (`FormSection.name` is optional today), which is a breaking authoring change + * needing a maintainer decision and a migration — deliberately not taken here. + * + * ── The opt-in gate: the object must actually be translated ────────────── + * + * A monolingual stack owes nobody a section name; warning there would be noise + * on the majority of stacks, and it would contradict the opt-in invariant the + * coverage gate already holds (`computeI18nCoverage`: declare no locales and no + * bundles, and the gate reports nothing rather than inventing a translation + * debt). So a section warns only when the object it renders under carries some + * translation of its own — i.e. the object appears under `objects.` in + * at least one `stack.translations` bundle. Then, and only then, is the heading + * the one string on that form which stays English while its neighbours resolve. + * + * The object-first `translation` METADATA TYPE (`o.` records persisted + * through the metadata store) is deliberately not read for that signal, for the + * same reason its sibling rule does not visit it: it is not `stack.translations` + * and this rule cannot see the store. Erring narrow costs a missed warning; + * erring wide costs a false one on a stack that never opted in. + * + * ── The section face ───────────────────────────────────────────────────── + * + * Exactly the anchors the two landed halves already agree on — the coverage + * walker's `section` kind (`packages/cli/src/utils/i18n-extract.ts`, #5416) and + * this package's `_sections` fact set (`validate-translation-references.ts` + * `addSections`, #5422): + * + * - a view container's `sections`, its DEFAULT `form.sections` (#5415 — the + * anchor that is neither a `formViews.*` entry nor the record's own), and + * every `listViews.*` / `formViews.*` sub-container's `sections`; + * - the same three on views embedded in an object (`objects[].views`, + * `objects[].listViews`); + * - `record:details` sections nested anywhere in a page's component tree, + * via the shared `walkPageComponents` traversal (never a private copy — + * duplicating that walk produced a dead rule once already, #3583). + * + * `fieldGroups`-derived sections are OUT of range by construction: their + * heading is keyed by `fieldGroups[].key`, so they always have a name. A + * `sections` authored as a name-keyed MAP is out of range for the same reason — + * the map key IS the name. + */ + +import { walkPageComponents } from './page-walk.js'; + +export const TRANSLATION_SECTION_NAME_MISSING = 'translation-section-name-missing'; + +export type TranslatableSectionSeverity = 'warning'; + +export interface TranslatableSectionFinding { + /** Always `warning` — one heading stays in the source locale; nothing breaks. */ + severity: TranslatableSectionSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `object "crm_case" · view "case_views" · formViews.create`. */ + where: string; + /** Config path, e.g. `views[0].formViews.create.sections[1]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * The object a view (or one of its containers) binds to, across the shapes it + * is authored in. Same ladder as `validate-translation-references.ts` and the + * CLI walker's `viewObjectName`, so all three agree on which object a heading + * belongs to — a container retargeted at another object keys its headings + * there, and disagreeing here would mean warning about the wrong object. + */ +function viewObjectName(view: AnyRec): string | undefined { + return ( + strName(view.objectName) ?? + strName(view.object) ?? + (isRec(view.data) ? strName(view.data.object) : undefined) + ); +} + +/** + * Entries of a collection authored either as an array or as a name-keyed map, + * each with the config path it actually sits at. The sibling rules coerce with + * `asArray` and lose the path; a rule that reports a location cannot. + */ +function collectionEntries(v: unknown, base: string): Array<{ rec: AnyRec; path: string }> { + if (Array.isArray(v)) { + const out: Array<{ rec: AnyRec; path: string }> = []; + for (let i = 0; i < v.length; i++) { + if (isRec(v[i])) out.push({ rec: v[i] as AnyRec, path: `${base}[${i}]` }); + } + return out; + } + if (isRec(v)) { + return Object.entries(v) + .filter(([, def]) => isRec(def)) + .map(([name, def]) => ({ rec: { name, ...(def as AnyRec) }, path: `${base}.${name}` })); + } + return []; +} + +/** One `sections` array, with where it sits and which object it renders under. */ +interface SectionSite { + /** Path of the `sections` array itself. */ + path: string; + /** + * Human-readable container the array hangs off (`formViews.create`), WITHOUT + * the object — the object is prepended once at emission so every finding in + * the family reads the same way. Empty when the container has no name of its + * own to add beyond what `path` already says. + */ + surface: string; + objectName?: string; + sections: unknown; +} + +/** `view "case_views" · ` when the container names itself, else nothing. */ +function viewLabel(view: AnyRec): string { + const name = strName(view.name); + return name ? `view "${name}"` : ''; +} + +/** Join the non-empty parts of a `where` line. */ +function joinWhere(...parts: string[]): string { + return parts.filter((p) => p.length > 0).join(' · '); +} + +/** + * Register every `sections` array ONE view container declares. + * + * The binding ladder mirrors `validate-translation-references.ts`'s + * `collectViewRecord` exactly: a sub-container resolves its own object first + * and falls back to the record's, then to the default list's — because on the + * canonical shape the binding lives INSIDE the container (`list.data.object`), + * not at the record root. + */ +function collectViewSites(view: AnyRec, basePath: string, label: string, sites: SectionSite[]): void { + const recordObject = viewObjectName(view); + const listBinding = isRec(view.list) ? viewObjectName(view.list) ?? recordObject : undefined; + const bindingOf = (container: AnyRec): string | undefined => + viewObjectName(container) ?? recordObject; + + sites.push({ + path: `${basePath}.sections`, + surface: label, + objectName: recordObject ?? listBinding, + sections: view.sections, + }); + + // The container's DEFAULT form — the one `defineView({ form: … })` declares + // and `ObjectForm` renders when no named form view is asked for (#5415). + if (isRec(view.form)) { + sites.push({ + path: `${basePath}.form.sections`, + surface: joinWhere(label, 'form'), + objectName: bindingOf(view.form) ?? listBinding, + sections: view.form.sections, + }); + } + + for (const key of ['listViews', 'formViews'] as const) { + const container = view[key]; + if (!isRec(container)) continue; + for (const [subKey, sub] of Object.entries(container)) { + if (!isRec(sub)) continue; + sites.push({ + path: `${basePath}.${key}.${subKey}.sections`, + surface: joinWhere(label, `${key}.${subKey}`), + objectName: bindingOf(sub) ?? listBinding, + sections: sub.sections, + }); + } + } +} + +/** Every object name some translation bundle carries a node for. */ +function translatedObjectNames(stack: AnyRec): Set { + const out = new Set(); + const bundles = Array.isArray(stack.translations) ? stack.translations : []; + for (const bundle of bundles) { + if (!isRec(bundle)) continue; + for (const data of Object.values(bundle)) { + if (!isRec(data) || !isRec(data.objects)) continue; + for (const [objectName, node] of Object.entries(data.objects)) { + if (isRec(node)) out.add(objectName); + } + } + } + return out; +} + +/** + * A snake_case name the author could adopt, derived from the heading text. + * + * A HINT ONLY. Nothing resolves this — see the module docstring: deriving a + * lookup key from label text is the second contract this rule exists to + * prevent. Returns `undefined` when the label yields nothing usable. + */ +function suggestedName(label: string): string | undefined { + const slug = label + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); + return slug.length > 0 ? slug : undefined; +} + +/** + * Report every form/detail section that declares a heading but no `name`, on an + * object the stack actually translates. Returns findings (empty = clean). + */ +export function validateTranslatableSections(stack: AnyRec): TranslatableSectionFinding[] { + const findings: TranslatableSectionFinding[] = []; + if (!isRec(stack)) return findings; + + // Opt-in gate: no bundles, no translated objects, nothing to warn about. + const translated = translatedObjectNames(stack); + if (translated.size === 0) return findings; + + const sites: SectionSite[] = []; + + // ── Objects: embedded views + the `listViews` container ── + for (const { rec: obj, path: objPath } of collectionEntries(stack.objects, 'objects')) { + const objectName = strName(obj.name); + if (!objectName) continue; + for (const { rec: view, path } of collectionEntries(obj.views, `${objPath}.views`)) { + collectViewSites( + { ...view, object: strName(view.object) ?? objectName }, + path, + viewLabel(view), + sites, + ); + } + if (isRec(obj.listViews)) { + collectViewSites({ object: objectName, listViews: obj.listViews }, objPath, '', sites); + } + } + + // ── Stack-level view containers ── + for (const { rec: view, path } of collectionEntries(stack.views, 'views')) { + collectViewSites(view, path, viewLabel(view), sites); + } + + // ── Pages: `record:details` sections, anywhere in the component tree ── + for (const { rec: page, path: pagePath } of collectionEntries(stack.pages, 'pages')) { + const pageName = strName(page.name); + const pageLabel = pageName ? `page "${pageName}"` : ''; + for (const walked of walkPageComponents(page, pagePath)) { + if (!walked.objectName) continue; + const props = isRec(walked.component.properties) ? walked.component.properties : undefined; + if (!props) continue; + const type = strName(walked.component.type) ?? 'component'; + sites.push({ + path: `${walked.path}.properties.sections`, + surface: joinWhere(pageLabel, type), + objectName: walked.objectName, + sections: props.sections, + }); + } + } + + for (const site of sites) { + const objectName = site.objectName; + if (!objectName || !translated.has(objectName)) continue; + // Only the ARRAY shape can be nameless: a name-keyed map supplies the name + // from its key, which is exactly the key `_sections` resolves against. + if (!Array.isArray(site.sections)) continue; + + for (let i = 0; i < site.sections.length; i++) { + const section = site.sections[i]; + if (!isRec(section)) continue; + if (strName(section.name)) continue; + // `record:details` reads `title ?? label`; form views author `label`. A + // section with neither has no heading rendered at all, so there is + // nothing untranslated to report — that is `required/label`'s question. + const heading = strName(section.label) ?? strName(section.title); + if (!heading) continue; + + const slug = suggestedName(heading); + findings.push({ + severity: 'warning', + rule: TRANSLATION_SECTION_NAME_MISSING, + where: joinWhere(`object "${objectName}"`, site.surface, `section "${heading}"`), + path: `${site.path}[${i}]`, + message: + `Section "${heading}" declares a label but no \`name\`. Headings resolve through ` + + `\`objects.${objectName}._sections..label\`, so a section with no name has no key ` + + `a bundle can carry — this heading can never be translated and renders in the source ` + + `locale in EVERY locale. Object "${objectName}" IS translated, which is what makes the ` + + `hole invisible: every neighbouring label resolves and only the heading stays behind. ` + + `The i18n coverage report cannot see it either — it walks \`sections[].name\`, and a ` + + `nameless section contributes nothing to walk.`, + hint: + `Give the section a stable \`name\` (snake_case)` + + (slug ? `, e.g. \`name: '${slug}'\`` : '') + + `, then translate it as \`objects.${objectName}._sections.${slug ?? ''}.label\` in ` + + `each locale bundle. The renderers look the heading up by name only — the name above ` + + `is a suggestion to write down, never a key derived from the label, so renaming the ` + + `heading later cannot break the lookup.`, + }); + } + } + + return findings; +}