Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .changeset/lint-translatable-sections.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('reference-integrity suite — membership', () => {
'validateNavAccess',
'validateNavTargetRefs',
'validateTranslationReferences',
'validateTranslatableSections',
'validateFlowTemplatePaths',
'validateAiSurfaceAffinity',
'validateAiToolReferences',
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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');
Expand Down
9 changes: 9 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 },
Expand Down
Loading
Loading