Skip to content

Commit 8599c21

Browse files
qq9340100claude
andauthored
fix(cli,lint): read only the declared label spelling for section headings (#5730) (#6616)
`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. Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78f0be8 commit 8599c21

5 files changed

Lines changed: 94 additions & 23 deletions

File tree

.changeset/tame-donkeys-repeat.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/cli': patch
3+
'@objectstack/lint': patch
4+
---
5+
6+
i18n section headings: read only the declared `label` spelling, never `title`
7+
8+
`record:details` sections and form-view sections declare exactly one heading key —
9+
`label` (`RecordDetailsProps.sections[]` and `FormSectionSchema`; #5611 settled this
10+
by declaring `label` and deliberately NOT declaring `title`). Two consumers still
11+
read `label ?? title`:
12+
13+
- `os i18n extract` / `os lint`'s coverage walk (`i18n-extract.ts`) scaffolded
14+
`objects.<object>._sections.<name>.label` from a `title`;
15+
- the `translation-section-name-missing` lint rule accepted a `title` as the
16+
heading it reports on.
17+
18+
Both now read `label` only. Per Prime Directive #12 the tolerance was the bug: a
19+
consumer that reads an undeclared spelling turns it into a second de-facto contract,
20+
and here it did so on the loudest possible surface — the extractor would seed a
21+
translation bundle key from a spelling the schema rejects, teaching the wrong key to
22+
every translator downstream.
23+
24+
FROM → TO: a section authored as `{ name: 'timeline', title: 'Timeline' }` becomes
25+
`{ name: 'timeline', label: 'Timeline' }`. No migration is expected in practice —
26+
every `record:details` section in this repo and in `packages/platform-objects`
27+
already authors `label` (~12 sections, zero `title`).
28+
29+
Behaviour change if you do author `title`: the heading is treated as absent. The
30+
extractor still emits the section's expected key (it is derived from `name`) but
31+
seeds it with the section name instead of your `title` text, and the lint rule no
32+
longer reports that section. Rename the key to `label` — which is also what the
33+
schema itself will tell you, since `title` is not a declared key there.

packages/cli/src/utils/i18n-extract.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,14 @@ function addSectionList(index: SectionIndex, sections: unknown, objectName: unkn
439439
for (const section of sections) {
440440
if (!section || typeof section !== 'object') continue;
441441
const s = section as Record<string, unknown>;
442-
// `record:details` reads `title ?? label`, form views author `label`; a
443-
// localized-map label (`{ en, 'zh-CN' }`) is already multilingual and
442+
// `label` is the ONE heading spelling both surfaces declare —
443+
// `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730).
444+
// A `title` here is off-spec and deliberately unread: reading it would
445+
// scaffold a bundle key for a heading the schema rejects, which is how a
446+
// consumer-side tolerance grows into a second de-facto contract (PD #12).
447+
// A localized-map label (`{ en, 'zh-CN' }`) is already multilingual and
444448
// `inlineText` drops it to "nothing authored in plain text".
445-
addSection(index, objectName, s.name, s.label ?? s.title);
449+
addSection(index, objectName, s.name, s.label);
446450
}
447451
}
448452

packages/cli/test/i18n-section-coverage.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ describe('authored record-page sections', () => {
217217
properties: {
218218
sections: [
219219
{ name: 'overview', label: 'Overview', fields: ['name'] },
220-
{ name: 'timeline', title: 'Timeline', fields: ['close_date'] },
220+
{ name: 'timeline', label: 'Timeline', fields: ['close_date'] },
221221
],
222222
},
223223
},
@@ -236,11 +236,30 @@ describe('authored record-page sections', () => {
236236
]);
237237
});
238238

239-
it('reads `title` as the source text too — that is what `record:details` renders', () => {
239+
it('reads `label` as the source text', () => {
240240
const timeline = sectionEntries({ pages: [slottedPage] }).find((e) => e.path[3] === 'timeline');
241241
expect(timeline?.inline).toBe('Timeline');
242242
});
243243

244+
it('does NOT read an off-spec `title` as the source text — the key is still emitted, empty', () => {
245+
// #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]`
246+
// declares (#5611). The walk keys sections off `name`, so a `title`-only
247+
// section STILL contributes its expected key — what it must not contribute
248+
// is source text, because scaffolding `"Timeline"` into a bundle from an
249+
// off-spec key is how the second spelling would become self-documenting.
250+
// Asserting the key survives is what keeps this non-vacuous: the entry is
251+
// present and its `inline` is empty, not absent because nothing was walked.
252+
const titled = JSON.parse(JSON.stringify(slottedPage));
253+
const sections = titled.slots.tabs.properties.items[0].children[0].properties.sections;
254+
sections[1] = { name: 'timeline', title: 'Timeline', fields: ['close_date'] };
255+
256+
const entries = sectionEntries({ pages: [titled] });
257+
expect(entries.map((e) => e.path.join('.'))).toContain(
258+
'objects.crm_opportunity._sections.timeline.label',
259+
);
260+
expect(entries.find((e) => e.path[3] === 'timeline')?.inline).toBeUndefined();
261+
});
262+
244263
it('reaches the plain `regions[].components[]` shape, and never mines a page REGION name', () => {
245264
// `PageSchema.aliases` maps `sections` → `regions`, and a region carries a
246265
// `name` exactly like a section does. The shared walk enters at

packages/lint/src/validate-translatable-sections.test.ts

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -314,24 +314,37 @@ describe('validateTranslatableSections — what it deliberately leaves alone', (
314314
expect(findings).toEqual([]);
315315
});
316316

317-
it('reads a detail section\'s `title` as its heading', () => {
318-
// `record:details` reads `title ?? label` — a nameless section with only a
319-
// `title` renders a heading just the same.
320-
const findings = validateTranslatableSections({
321-
objects: [crmCase],
322-
pages: [
323-
{
324-
name: 'case_detail',
325-
object: 'crm_case',
326-
regions: [{ components: [{ type: 'record:details', properties: { sections: [{ title: 'SLA' }] } }] }],
327-
},
328-
],
329-
translations: caseTranslated,
330-
});
317+
// #5730: `label` is the only heading spelling `RecordDetailsProps.sections[]`
318+
// declares (#5611). The rule used to read `label ?? title`, which meant an
319+
// off-spec `title` earned a translation-shaped warning — telling the author
320+
// their `title` heading needed a `name` to be translatable, i.e. teaching the
321+
// spelling the schema rejects. The two cases below are one pin, deliberately
322+
// PAIRED: the `label` case proves the walk reaches this component at all, so
323+
// the `title` case's empty result is the tolerance being gone and not the
324+
// fixture failing to arrive (#5046's "green because nothing was produced").
325+
const namelessDetailSection = (section: Record<string, unknown>) => ({
326+
objects: [crmCase],
327+
pages: [
328+
{
329+
name: 'case_detail',
330+
object: 'crm_case',
331+
regions: [{ components: [{ type: 'record:details', properties: { sections: [section] } }] }],
332+
},
333+
],
334+
translations: caseTranslated,
335+
});
336+
337+
it('reads a detail section\'s `label` as its heading', () => {
338+
const findings = validateTranslatableSections(namelessDetailSection({ label: 'SLA' }));
331339
expect(findings).toHaveLength(1);
332340
expect(findings[0].where).toContain('section "SLA"');
333341
});
334342

343+
it('does NOT read a detail section\'s off-spec `title` as its heading', () => {
344+
const findings = validateTranslatableSections(namelessDetailSection({ title: 'SLA' }));
345+
expect(findings).toEqual([]);
346+
});
347+
335348
it('keys a retargeted component under the object it actually binds', () => {
336349
// A `record:details` pointed at another object keys its headings THERE, so
337350
// the gate must consult that object's translations, not the page's.

packages/lint/src/validate-translatable-sections.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,10 +323,12 @@ export function validateTranslatableSections(stack: AnyRec): TranslatableSection
323323
const section = site.sections[i];
324324
if (!isRec(section)) continue;
325325
if (strName(section.name)) continue;
326-
// `record:details` reads `title ?? label`; form views author `label`. A
327-
// section with neither has no heading rendered at all, so there is
328-
// nothing untranslated to report — that is `required/label`'s question.
329-
const heading = strName(section.label) ?? strName(section.title);
326+
// `label` is the ONE heading spelling both surfaces declare —
327+
// `RecordDetailsProps.sections[]` and `FormSectionSchema` (#5611, #5730).
328+
// A section with no `label` has no heading the schema recognises, so
329+
// there is nothing untranslated to report — that is `required/label`'s
330+
// question, and an off-spec `title` is its business, not this rule's.
331+
const heading = strName(section.label);
330332
if (!heading) continue;
331333

332334
const slug = suggestedName(heading);

0 commit comments

Comments
 (0)