From 56aef0acae5c6436dffd1d5d4c9befed1b163719 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 07:21:59 +0000 Subject: [PATCH] fix(cli,lint): read only the declared `label` spelling for section headings (#5730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `record:details` sections and form-view sections declare exactly one heading key — `label`. #5611 settled this by declaring `label` on `RecordDetailsProps.sections[]` and deliberately NOT declaring `title`; `FormSectionSchema` has only ever declared `label`. Two consumers still read `label ?? title`: - packages/cli/src/utils/i18n-extract.ts (addSectionList) - packages/lint/src/validate-translatable-sections.ts Both now read `label` only. Per Prime Directive #12 the tolerance is the bug: a consumer that reads an undeclared spelling turns it into a second de-facto contract. The extractor is the worst place for it — it WRITES translation bundles, so it would seed a bundle key from a spelling the schema rejects and teach that key to every translator downstream. Zero migration: all ~12 `record:details` sections in this repo (three showcase pages plus packages/platform-objects/src/pages/sys-user.page.ts) already author `label`; no real authoring surface spells `title`. Fixtures are triaged individually rather than re-spelled in bulk (#5046): - i18n-section-coverage.test.ts:220 — re-spell (`title:` -> `label:`). - validate-translatable-sections.test.ts — replace wholesale. That test pinned exactly the deleted limb, so after the change it would keep passing because NOTHING is produced, not because the logic is right. Both replacements are written as PAIRS so neither can pass vacuously: the `label` case proves the walk reaches the site, and the `title` case's empty result therefore reads as "the tolerance is gone" rather than "the fixture never arrived". The cli pin additionally asserts the section's expected key is still emitted (it derives from `name`) while its inline source text is empty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .changeset/tame-donkeys-repeat.md | 33 +++++++++++++++ packages/cli/src/utils/i18n-extract.ts | 10 +++-- .../cli/test/i18n-section-coverage.test.ts | 23 ++++++++++- .../validate-translatable-sections.test.ts | 41 ++++++++++++------- .../src/validate-translatable-sections.ts | 10 +++-- 5 files changed, 94 insertions(+), 23 deletions(-) create mode 100644 .changeset/tame-donkeys-repeat.md diff --git a/.changeset/tame-donkeys-repeat.md b/.changeset/tame-donkeys-repeat.md new file mode 100644 index 0000000000..dc5c9f6e3d --- /dev/null +++ b/.changeset/tame-donkeys-repeat.md @@ -0,0 +1,33 @@ +--- +'@objectstack/cli': patch +'@objectstack/lint': patch +--- + +i18n section headings: read only the declared `label` spelling, never `title` + +`record:details` sections and form-view sections declare exactly one heading key — +`label` (`RecordDetailsProps.sections[]` and `FormSectionSchema`; #5611 settled this +by declaring `label` and deliberately NOT declaring `title`). Two consumers still +read `label ?? title`: + +- `os i18n extract` / `os lint`'s coverage walk (`i18n-extract.ts`) scaffolded + `objects.._sections..label` from a `title`; +- the `translation-section-name-missing` lint rule accepted a `title` as the + heading it reports on. + +Both now read `label` only. Per Prime Directive #12 the tolerance was the bug: a +consumer that reads an undeclared spelling turns it into a second de-facto contract, +and here it did so on the loudest possible surface — the extractor would seed a +translation bundle key from a spelling the schema rejects, teaching the wrong key to +every translator downstream. + +FROM → TO: a section authored as `{ name: 'timeline', title: 'Timeline' }` becomes +`{ name: 'timeline', label: 'Timeline' }`. No migration is expected in practice — +every `record:details` section in this repo and in `packages/platform-objects` +already authors `label` (~12 sections, zero `title`). + +Behaviour change if you do author `title`: the heading is treated as absent. The +extractor still emits the section's expected key (it is derived from `name`) but +seeds it with the section name instead of your `title` text, and the lint rule no +longer reports that section. Rename the key to `label` — which is also what the +schema itself will tell you, since `title` is not a declared key there. diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 36e7dff897..a231d636de 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -439,10 +439,14 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn for (const section of sections) { if (!section || typeof section !== 'object') continue; const s = section as Record; - // `record:details` reads `title ?? label`, form views author `label`; a - // localized-map label (`{ en, 'zh-CN' }`) is already multilingual and + // `label` is the ONE heading spelling both surfaces declare — + // `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730). + // A `title` here is off-spec and deliberately unread: reading it would + // scaffold a bundle key for a heading the schema rejects, which is how a + // consumer-side tolerance grows into a second de-facto contract (PD #12). + // A localized-map label (`{ en, 'zh-CN' }`) is already multilingual and // `inlineText` drops it to "nothing authored in plain text". - addSection(index, objectName, s.name, s.label ?? s.title); + addSection(index, objectName, s.name, s.label); } } diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts index 734efb13cf..53e7f9105d 100644 --- a/packages/cli/test/i18n-section-coverage.test.ts +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -217,7 +217,7 @@ describe('authored record-page sections', () => { properties: { sections: [ { name: 'overview', label: 'Overview', fields: ['name'] }, - { name: 'timeline', title: 'Timeline', fields: ['close_date'] }, + { name: 'timeline', label: 'Timeline', fields: ['close_date'] }, ], }, }, @@ -236,11 +236,30 @@ describe('authored record-page sections', () => { ]); }); - it('reads `title` as the source text too — that is what `record:details` renders', () => { + it('reads `label` as the source text', () => { const timeline = sectionEntries({ pages: [slottedPage] }).find((e) => e.path[3] === 'timeline'); expect(timeline?.inline).toBe('Timeline'); }); + it('does NOT read an off-spec `title` as the source text — the key is still emitted, empty', () => { + // #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]` + // declares (#5611). The walk keys sections off `name`, so a `title`-only + // section STILL contributes its expected key — what it must not contribute + // is source text, because scaffolding `"Timeline"` into a bundle from an + // off-spec key is how the second spelling would become self-documenting. + // Asserting the key survives is what keeps this non-vacuous: the entry is + // present and its `inline` is empty, not absent because nothing was walked. + const titled = JSON.parse(JSON.stringify(slottedPage)); + const sections = titled.slots.tabs.properties.items[0].children[0].properties.sections; + sections[1] = { name: 'timeline', title: 'Timeline', fields: ['close_date'] }; + + const entries = sectionEntries({ pages: [titled] }); + expect(entries.map((e) => e.path.join('.'))).toContain( + 'objects.crm_opportunity._sections.timeline.label', + ); + expect(entries.find((e) => e.path[3] === 'timeline')?.inline).toBeUndefined(); + }); + it('reaches the plain `regions[].components[]` shape, and never mines a page REGION name', () => { // `PageSchema.aliases` maps `sections` → `regions`, and a region carries a // `name` exactly like a section does. The shared walk enters at diff --git a/packages/lint/src/validate-translatable-sections.test.ts b/packages/lint/src/validate-translatable-sections.test.ts index 69d7050b08..7efed244f6 100644 --- a/packages/lint/src/validate-translatable-sections.test.ts +++ b/packages/lint/src/validate-translatable-sections.test.ts @@ -314,24 +314,37 @@ describe('validateTranslatableSections — what it deliberately leaves alone', ( 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, - }); + // #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]` + // declares (#5611). The rule used to read `label ?? title`, which meant an + // off-spec `title` earned a translation-shaped warning — telling the author + // their `title` heading needed a `name` to be translatable, i.e. teaching the + // spelling the schema rejects. The two cases below are one pin, deliberately + // PAIRED: the `label` case proves the walk reaches this component at all, so + // the `title` case's empty result is the tolerance being gone and not the + // fixture failing to arrive (#5046's "green because nothing was produced"). + const namelessDetailSection = (section: Record) => ({ + objects: [crmCase], + pages: [ + { + name: 'case_detail', + object: 'crm_case', + regions: [{ components: [{ type: 'record:details', properties: { sections: [section] } }] }], + }, + ], + translations: caseTranslated, + }); + + it('reads a detail section\'s `label` as its heading', () => { + const findings = validateTranslatableSections(namelessDetailSection({ label: 'SLA' })); expect(findings).toHaveLength(1); expect(findings[0].where).toContain('section "SLA"'); }); + it('does NOT read a detail section\'s off-spec `title` as its heading', () => { + const findings = validateTranslatableSections(namelessDetailSection({ title: 'SLA' })); + expect(findings).toEqual([]); + }); + 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. diff --git a/packages/lint/src/validate-translatable-sections.ts b/packages/lint/src/validate-translatable-sections.ts index c65931fcb0..30d8691db2 100644 --- a/packages/lint/src/validate-translatable-sections.ts +++ b/packages/lint/src/validate-translatable-sections.ts @@ -323,10 +323,12 @@ export function validateTranslatableSections(stack: AnyRec): TranslatableSection 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); + // `label` is the ONE heading spelling both surfaces declare — + // `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730). + // A section with no `label` has no heading the schema recognises, so + // there is nothing untranslated to report — that is `required/label`'s + // question, and an off-spec `title` is its business, not this rule's. + const heading = strName(section.label); if (!heading) continue; const slug = suggestedName(heading);