Skip to content

Commit 61fde5e

Browse files
baozhoutaoclaude
andauthored
feat(lint): warn when a form section has a label but no name — the heading no key can address (#5417) (#5455)
`_sections` is keyed by the section's `name`, and every renderer resolves a section heading that way (`sectionLabel(objectName, section.name, authored)`), 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 both existing gates are structurally blind to it: there is no orphan key for `validateTranslationReferences` to report, and the coverage walker (#5405) emits one expected key per `sections[].name`, so a nameless section demands nothing and the report reads 100%. Measured: 70/70 HotCRM form-view sections are in that state at full declared coverage; `os validate` over examples/app-showcase reports 14 (6 form views, 8 record:details pages) and still exits 0. `validateTranslatableSections` (`translation-section-name-missing`) joins the reference-integrity suite, so it runs on validate/lint/compile at once, and reads exactly the anchors #5416's walker and #5422's fact set already agree on — a container's `sections`, its DEFAULT `form.sections`, every `listViews.*`/`formViews.*` sub-container, the same three on object-embedded views, and `record:details` sections anywhere in a page's component tree via the shared `walkPageComponents`. fieldGroups-derived sections are out of range by construction (their key IS the name), as is a name-keyed `sections` map. Warning, and opt-in: a section warns only when the object it renders under carries some translation of its own, so a monolingual stack stays silent exactly as `computeI18nCoverage` does. The fix is a diagnostic at the producer — slugifying the label into a lookup key would fossilize a second de-facto contract next to the declared one (Prime Directive #12, and the renderer guard objectui#3373 pinned as correct). Per the PM ruling on the issue, requiring `name` on `FormSection` outright (the schema route) is a breaking authoring change left to the maintainer; packages/spec is untouched. Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31b0537 commit 61fde5e

6 files changed

Lines changed: 896 additions & 0 deletions
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
lint: warn when a form section declares a `label` but no `name` — the heading no translation key can ever address
6+
7+
`_sections` is keyed by the section's `name`, and every renderer that draws a
8+
section heading resolves it that way (`sectionLabel(objectName, section.name,
9+
authored)``plugin-form`'s `ObjectForm`/`ModalForm`, `plugin-detail`'s
10+
`record:details`), falling back to the authored label when there is no name.
11+
So a section authored with a `label` and no `name` is untranslatable **by
12+
construction**, and every gate we own was structurally blind to it:
13+
14+
- the reference validator reports keys a bundle carries that nothing declares —
15+
a nameless section produces no key, so there is no orphan to report;
16+
- the i18n coverage walk (#5405) emits one expected key per `sections[].name`
17+
a section with no name contributes nothing to demand, so the report reads
18+
100% while the heading renders in the source locale in every locale.
19+
20+
Measured on HotCRM: **70 of 70** form-view sections across all 14 view files are
21+
in exactly that state, with four locales at full declared coverage and zero
22+
warnings anywhere. It is also the real cause of the reported `Case / SLA /
23+
Resolution` English strip — that object's *detail page* sections carry names and
24+
translate, while its *form view* sections carry none.
25+
26+
`validateTranslatableSections` (rule id `translation-section-name-missing`) joins
27+
the reference-integrity suite, so it runs on `os validate`, `os lint` and
28+
`os compile` at once. It reads exactly the anchors the two landed halves already
29+
agree on: a view container's `sections`, its **default** `form.sections`, every
30+
`listViews.*` / `formViews.*` sub-container, the same three on views embedded in
31+
an object, and `record:details` sections nested anywhere in a page's component
32+
tree. `fieldGroups`-derived sections are out of range by construction — their
33+
heading is keyed by `fieldGroups[].key`, so they always have a name.
34+
35+
**Warning, and opt-in.** Nothing crashes and nothing is dead — one heading stays
36+
in the source locale — so the severity matches its sibling rules (ADR-0072 D1)
37+
and nothing that passed before starts failing. `os validate` over
38+
`examples/app-showcase` now reports 14 of these (6 from form views, 8 from
39+
`record:details` pages) and still exits 0. A section warns only when the
40+
object it renders under carries some translation of its own, which keeps the
41+
monolingual case silent exactly as the coverage gate already does.
42+
43+
The fix is a diagnostic at the **producer**, deliberately not tolerance at the
44+
consumer: deriving a lookup key by slugifying the label would fossilize a second
45+
de-facto contract next to the declared one, and would move the day anyone edits
46+
the heading text. The `name` the hint suggests is a suggestion for the author to
47+
write down, never a key anything resolves.

packages/lint/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,18 @@ export type {
311311
TranslationRefSeverity,
312312
} from './validate-translation-references.js';
313313

314+
// The other end of the same question (#5417): a form section authored with a
315+
// `label` and no `name` renders a heading `_sections` can never address — no
316+
// orphan key to report, and nothing for the coverage walk to demand.
317+
export {
318+
validateTranslatableSections,
319+
TRANSLATION_SECTION_NAME_MISSING,
320+
} from './validate-translatable-sections.js';
321+
export type {
322+
TranslatableSectionFinding,
323+
TranslatableSectionSeverity,
324+
} from './validate-translatable-sections.js';
325+
314326
export {
315327
validateAiSurfaceAffinity,
316328
AI_SKILL_SURFACE_MISMATCH,

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe('reference-integrity suite — membership', () => {
2323
'validateNavAccess',
2424
'validateNavTargetRefs',
2525
'validateTranslationReferences',
26+
'validateTranslatableSections',
2627
'validateFlowTemplatePaths',
2728
'validateAiSurfaceAffinity',
2829
'validateAiToolReferences',
@@ -94,6 +95,15 @@ describe('reference-integrity suite — every member actually runs', () => {
9495
// validateActionNameRefs: no such action.
9596
bulkActions: ['mass_update'],
9697
},
98+
// validateTranslatableSections (#5417): a form section authored with a
99+
// `label` and no `name`. `_sections` is keyed by name, so this heading
100+
// has no key any bundle can carry — and crm_lead IS translated below,
101+
// which is what opens the rule's opt-in gate.
102+
form: {
103+
type: 'simple',
104+
data: { provider: 'object', object: 'crm_lead' },
105+
sections: [{ label: 'Qualification', columns: 2, fields: ['name'] }],
106+
},
97107
},
98108
],
99109
pages: [
@@ -228,6 +238,7 @@ describe('reference-integrity suite — every member actually runs', () => {
228238
expect(rules).toContain('chart-measure-unknown');
229239
expect(rules).toContain('nav-object-ungranted');
230240
expect(rules).toContain('translation-target-unknown');
241+
expect(rules).toContain('translation-section-name-missing');
231242
expect(rules).toContain('flow-template-unknown-field');
232243
expect(rules).toContain('ai-skill-surface-mismatch');
233244
expect(rules).toContain('ai-skill-tool-unresolved');

packages/lint/src/reference-integrity-suite.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { validateChartBindings } from './validate-chart-bindings.js';
6262
import { validateNavAccess } from './validate-nav-access.js';
6363
import { validateNavTargetRefs } from './validate-nav-target-refs.js';
6464
import { validateTranslationReferences } from './validate-translation-references.js';
65+
import { validateTranslatableSections } from './validate-translatable-sections.js';
6566
import { validateFlowTemplatePaths } from './validate-flow-template-paths.js';
6667
import { validateAiSurfaceAffinity } from './validate-ai-surface-affinity.js';
6768
import { validateAiToolReferences } from './validate-ai-tool-references.js';
@@ -120,6 +121,14 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
120121
// `component` (an unregistered ref renders a named diagnostic, not silence).
121122
{ name: 'validateNavTargetRefs', run: validateNavTargetRefs },
122123
{ name: 'validateTranslationReferences', run: validateTranslationReferences },
124+
// The same family from the other end (#5417). Its sibling above asks "does
125+
// this bundle key resolve?"; this one asks "is there a key at all?" — a form
126+
// section authored with a `label` and no `name` renders a heading that
127+
// `_sections` (keyed by name) can never address, so neither the orphan check
128+
// nor the coverage walk can see it. A reference that cannot be written is
129+
// still a reference question, and warning-only for the same reason its
130+
// sibling is: one heading stays in the source locale, nothing breaks.
131+
{ name: 'validateTranslatableSections', run: validateTranslatableSections },
123132
{ name: 'validateFlowTemplatePaths', run: validateFlowTemplatePaths },
124133
{ name: 'validateAiSurfaceAffinity', run: validateAiSurfaceAffinity },
125134
{ name: 'validateAiToolReferences', run: validateAiToolReferences },

0 commit comments

Comments
 (0)