From e0c28905837c5ac73dd5143833133129b01a94e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 10:22:15 +0000 Subject: [PATCH 1/2] fix(cli): i18n walker collects `objects.._sections` (#5405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExpectedEntry['source']` had no `section` kind, so `objects.._sections..label` was structurally unreachable: `os i18n extract` never scaffolded a heading and `os lint` could not report one missing — while `ObjectTranslationDataSchema` declares `_sections` and `@object-ui/i18n`'s `sectionLabel` resolves it for `record:details`, `ObjectForm`/`ModalForm` and the field-group designer. Adds a `section` kind fed by both authoring surfaces, deduped to one key per (object, section): (a) `fieldGroups` × field `group`, read through the shared ADR-0085 `deriveFieldGroupLayout` so a group nothing visible references — or a `group:` no `fieldGroups` entry declares — yields no key; (b) a NAMED `sections[]` on a form view (including a container's default `form`) or inside a record page's component tree. `COVERAGE_SOURCE` maps it, giving `os lint` an `i18n/missing-section` category in the user bucket (not folded away with `--include-platform`). Page sections go through `@objectstack/lint`'s shared page traversal, now exported, instead of a private copy — that walk exists because duplicating it produced a dead rule once (#3583), and it is what makes this half correct rather than merely present: `slots.`, the untyped `page:tabs` -> `properties.items[].children[]` nesting, skipping source-authored pages whose regions are a derived cache, and per component binding resolution so a re-bound `record:details` keys its headings under the object it actually shows. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .changeset/i18n-walker-object-sections.md | 64 +++ packages/cli/src/utils/i18n-coverage.ts | 7 + packages/cli/src/utils/i18n-extract.ts | 170 ++++++ .../cli/test/i18n-section-coverage.test.ts | 526 ++++++++++++++++++ packages/lint/src/index.ts | 8 + 5 files changed, 775 insertions(+) create mode 100644 .changeset/i18n-walker-object-sections.md create mode 100644 packages/cli/test/i18n-section-coverage.test.ts diff --git a/.changeset/i18n-walker-object-sections.md b/.changeset/i18n-walker-object-sections.md new file mode 100644 index 0000000000..3b61a7a4d9 --- /dev/null +++ b/.changeset/i18n-walker-object-sections.md @@ -0,0 +1,64 @@ +--- +"@objectstack/cli": minor +"@objectstack/lint": minor +--- + +fix(cli): the i18n walker collects `objects.._sections` — section headings are gated and scaffolded like every other label (#5405) + +An object's SECTION headings were the one declared, resolved, rendered +translation surface the shared i18n walker had no kind for. +`ExpectedEntry['source']` listed `object | field | option | view | action | +globalAction | app | navigation | dashboard | widget | page` plus two +`metadataForm*` kinds — and those two cover **Studio metadata forms** +(`metadataForms..sections.*`, hidden behind `--include-platform`), not app +objects. So `objects.._sections..label` was structurally unreachable in +both directions: `os i18n extract` never scaffolded a heading, and +`os lint` could not report one missing. + +The surface itself was never in doubt. `ObjectTranslationDataSchema` declares +`_sections` (with `sections` as an authoring alias), and `@object-ui/i18n`'s +`sectionLabel` resolves it for `record:details`, for `ObjectForm`/`ModalForm` +and for the field-group designer. Only the walker disagreed — which is exactly +the drift `collectExpectedEntries` was consolidated to prevent (#3370). + +Measured downstream before this landed: 85 sections across 15 objects, **2 of +85** translated in `ja-JP` and in `es-ES` — English headings on essentially +every record page and form — with `objectstack lint` reporting **zero** i18n +warnings for both locales. + +**What is collected.** A `section` kind, from the two independent surfaces that +both resolve to the same key — a heading is expected if *either* declares it, +and one heading is one expected key however many declare it: + +- **`fieldGroups` × field `group`** — the fields decide which sections exist and + `fieldGroups[].label` supplies the source text. Membership is read through + `deriveFieldGroupLayout` (ADR-0085 §5), the same shared derivation the + renderers consume, so a group nothing visible references — or a `group:` no + `fieldGroups` entry declares — produces no heading and therefore no expected + key. The trailing ungrouped bucket renders without chrome and is skipped. +- **A named `sections[]`** on a form view (including a view container's default + `form`) or inside a record page's component tree. + +A section with no `name` is skipped: every renderer guards the lookup on it +(`s?.name ? sectionLabel(…) : s?.label`), so it is untranslatable by +construction and demanding a bundle entry for it would be noise. A group that +declares no `label` still yields a scaffold key seeded from its own name, but no +coverage finding — nobody authored that text. + +**What you get.** `os lint` gains an `i18n/missing-section` category — user +metadata, so it is reported without `--include-platform` — and `os i18n +extract` scaffolds the headings for free, because the gate and the extractor +read the one walker. A project that declares no locales still reports nothing; +the gate stays opt-in. + +**`@objectstack/lint`** now exports its shared page traversal +(`walkPageComponents`, `isSourceAuthoredPage`, `WalkedComponent`) so the CLI +consumes it instead of growing a private copy — that walk exists precisely +because duplicating it produced a dead rule once already (#3583). Reusing it is +also what makes the page half correct rather than merely present: it reaches +`slots.` and the untyped nesting a record page really uses +(`page:tabs` → `properties.items[].children[]` → `record:details`), skips +source-authored pages whose `regions` are a derived cache, and resolves each +component's OWN binding (`dataSource.object` → `properties.object` → the page's +`object`) — so a re-bound `record:details` keys its headings under the object it +actually shows. diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index e649631a83..cf3d94e7e5 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -47,6 +47,7 @@ export interface CoverageIssue { | 'object' | 'field' | 'option' + | 'section' | 'view' | 'action' | 'globalAction' @@ -191,6 +192,11 @@ const COVERAGE_SOURCE: Record object: 'object', field: 'field', option: 'option', + // An object section heading (`objects.._sections..label`) is the + // user's own metadata, not the Studio-form baseline — it keeps its own + // bucket so `os lint` reports it as `i18n/missing-section` rather than + // folding it away with `--include-platform`. + section: 'section', view: 'view', action: 'action', globalAction: 'globalAction', @@ -208,6 +214,7 @@ const SOURCE_NOUN: Record = { object: 'Object', field: 'Field', option: 'Option', + section: 'Section', view: 'View', action: 'Action', globalAction: 'Global action', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index a99a741393..c9c39502ee 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -30,6 +30,7 @@ * objects..fields..help * objects..fields..placeholder * objects..fields..options. + * objects.._sections.
.label * objects.._views..label * objects.._views..description * objects.._views..emptyState.title / .message @@ -64,6 +65,8 @@ import type { TranslationBundle, TranslationData } from '@objectstack/spec/system'; import { METADATA_FORM_REGISTRY } from '@objectstack/spec/system'; import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel'; +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; +import { walkPageComponents } from '@objectstack/lint'; // ─── Public types ────────────────────────────────────────────────────── @@ -95,6 +98,7 @@ export interface ExpectedEntry { | 'object' | 'field' | 'option' + | 'section' | 'view' | 'action' | 'globalAction' @@ -332,6 +336,165 @@ function pushActionResultDialog( } } +// ─── Object sections (`objects.._sections.
.label`) ───────── +// +// A section heading is authored in TWO independent places and rendered from +// both, so a walk that reads only one of them under-reports (#5405): +// +// (a) the object's `fieldGroups` semantic role (ADR-0085 §5) — the fields +// are the authority for which sections exist (a declared group nothing +// references never renders), `fieldGroups[].label` supplies the source +// text. Rendered by `RecordDetailView` via +// `deriveFieldGroupDetailSections` and by `ObjectFormDesigner`, both of +// which look the heading up as `sectionLabel(object, group.key, …)`. +// +// (b) authored `sections[]` on a form view or inside a record page's +// component tree, keyed by the section's own `name`. Rendered by +// `plugin-form`'s `ObjectForm`/`ModalForm` and `plugin-detail`'s +// `record:details`. +// +// Both resolve through the SAME convention — +// `objects.._sections..label`, `useObjectLabel.sectionLabel` in +// `@object-ui/i18n` — against the `_sections` slot `ObjectTranslationDataSchema` +// declares. So one expected key per (object, section), whichever source found +// it first. +// +// A section with no `name` is deliberately skipped: every renderer guards the +// lookup on it (`s?.name ? sectionLabel(...) : s?.label`), so a nameless +// section is untranslatable by construction and demanding a bundle entry for +// it would be noise. The `_sections` schema says the same ("Each section in +// the page schema must declare a stable `name` for the lookup to fire"). + +/** object name → section name → source label (undefined = none authored). */ +type SectionIndex = Map>; + +/** + * Record one (object, section) pair. + * + * One heading, one key — however many surfaces declare it. The first AUTHORED + * label wins, and a later authored one upgrades an entry recorded without any + * (a group that declares no `label` of its own, whose heading text the page + * section carries): `inline` must report the text the reader actually sees, + * whichever surface happens to be walked first. + */ +function addSection(index: SectionIndex, objectName: unknown, sectionName: unknown, label: unknown): void { + if (typeof objectName !== 'string' || objectName.length === 0) return; + if (typeof sectionName !== 'string' || sectionName.length === 0) return; + let sections = index.get(objectName); + if (!sections) index.set(objectName, (sections = new Map())); + const authored = inlineText(label); + if (!sections.has(sectionName)) sections.set(sectionName, authored); + else if (sections.get(sectionName) === undefined && authored !== undefined) { + sections.set(sectionName, authored); + } +} + +/** Read a `sections[]` array (form view / component props) into the index. */ +function addSectionList(index: SectionIndex, sections: unknown, objectName: unknown): void { + if (!Array.isArray(sections)) return; + 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 + // `inlineText` drops it to "nothing authored in plain text". + addSection(index, objectName, s.name, s.label ?? s.title); + } +} + +/** + * Emit `objects.._sections.
.label` for every section the + * stack renders, from both authoring surfaces. + */ +function walkObjectSections(config: any, out: ExpectedEntry[]): void { + const index: SectionIndex = new Map(); + + // (a) `fieldGroups` × field `group` membership. `deriveFieldGroupLayout` is + // the shared derivation the renderers themselves consume, so "which + // groups become sections" is decided in exactly one place: a group no + // visible field references, or one that is referenced but never + // declared, produces no heading and therefore no expected key. + const objects: any[] = Array.isArray(config?.objects) ? config.objects : []; + for (const obj of objects) { + if (!obj?.name) continue; + const derived = deriveFieldGroupLayout(obj); + if (!derived) continue; + // The derivation substitutes the key for a missing label; read the + // declared label back so `inline` stays honest about what was authored. + const declaredLabels = new Map(); + for (const group of Array.isArray(obj.fieldGroups) ? obj.fieldGroups : []) { + if (group && typeof group === 'object' && typeof group.key === 'string') { + declaredLabels.set(group.key, group.label); + } + } + for (const section of derived) { + // The trailing ungrouped bucket carries no key — it renders without + // chrome, so there is no heading to translate. + if (section.key === undefined) continue; + addSection(index, obj.name, section.key, declaredLabels.get(section.key)); + } + } + + // (b) authored form-view sections. + const views: any[] = Array.isArray(config?.views) ? config.views : []; + for (const view of views) { + const containerObject = viewObjectName(view); + addSectionList(index, view?.sections, containerObject); + if (view?.form && typeof view.form === 'object') { + addSectionList(index, view.form.sections, viewObjectName(view.form) ?? containerObject); + } + if (view?.formViews && typeof view.formViews === 'object') { + for (const form of Object.values(view.formViews)) { + if (!form || typeof form !== 'object') continue; + addSectionList(index, form.sections, viewObjectName(form) ?? containerObject); + } + } + } + + // (b) authored page sections — a record page's `record:details`. + // + // Reuses `@objectstack/lint`'s shared page traversal rather than growing a + // private copy. That walk exists precisely because duplicating it produced a + // dead rule once already (#3583): components hang off `regions[].components` + // AND `slots.` (which may be a bare component, not an array), sub-trees + // live inside the untyped `properties` bag (`page:tabs` → + // `properties.items[].children`, `page:card` → `properties.body`/`.footer`), + // and source-authored pages (`kind: 'html' | 'react' | 'jsx'`) hold only a + // DERIVED region cache that the author never wrote — scaffolding translation + // keys off that cache would invent an authoring surface. + // + // It also resolves each component's OWN binding + // (`dataSource.object` → `properties.object` → the page's `object`), which a + // page-level-only binding would get wrong rather than merely miss: a + // `record:details` retargeted at another object would key its headings under + // the page's object, and no bundle entry there would ever resolve. + const pages: any[] = Array.isArray(config?.pages) ? config.pages : []; + for (let pi = 0; pi < pages.length; pi++) { + const page = pages[pi]; + if (!page || typeof page !== 'object') continue; + for (const walked of walkPageComponents(page, `pages[${pi}]`)) { + if (!walked.objectName) continue; + const props = walked.component.properties; + if (!props || typeof props !== 'object' || Array.isArray(props)) continue; + addSectionList(index, (props as Record).sections, walked.objectName); + } + } + + for (const [objectName, sections] of index) { + for (const [sectionName, label] of sections) { + pushDerived( + out, + ['objects', objectName, '_sections', sectionName, 'label'], + // Seed mirrors the renderer's own fallback (`s.label || s.name`). + label ?? sectionName, + label, + 'section', + { objectName }, + ); + } + } +} + /** Collect every translatable entry from a normalized stack config. */ export function collectExpectedEntries(config: any): ExpectedEntry[] { const out: ExpectedEntry[] = []; @@ -539,6 +702,13 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { } } + // ── Object sections (fieldGroups + authored form/page sections) ─── + // Deliberately a pass of its own: the two authoring surfaces live in + // `objects`, `views` and `pages`, and one section may be declared by more + // than one of them — collecting first and emitting once keeps a heading + // from being counted twice against coverage. + walkObjectSections(config, out); + // ── Metadata configuration forms (Studio admin UI) ──────────────── // Registry-driven: always included, independent of stack config. These // emit under `metadataForms..*` so the generic renderer can pick diff --git a/packages/cli/test/i18n-section-coverage.test.ts b/packages/cli/test/i18n-section-coverage.test.ts new file mode 100644 index 0000000000..734efb13cf --- /dev/null +++ b/packages/cli/test/i18n-section-coverage.test.ts @@ -0,0 +1,526 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#5405 — an object's SECTION headings were the one declared, +// resolved, rendered translation surface the shared walker had no kind for. +// `ExpectedEntry['source']` listed object/field/option/view/action/... and two +// `metadataForm*` kinds (Studio metadata forms, a different namespace), so +// `objects.._sections..label` was structurally unreachable: `os i18n +// extract` never scaffolded a heading and `os lint` could not report one +// missing. Measured downstream (hotcrm#697): 85 sections across 15 objects, +// 2 of 85 translated in ja-JP and es-ES, and `objectstack lint` reported +// ZERO i18n warnings for both. +// +// The surface itself was never in doubt — `ObjectTranslationDataSchema` +// declares `_sections` (with `sections` as an authoring alias), and +// `@object-ui/i18n`'s `sectionLabel` resolves it for `record:details`, for +// `ObjectForm`/`ModalForm` and for the field-group designer. Only the walker +// disagreed. +// +// Two authoring surfaces feed one key, and BOTH are load-bearing: +// (a) `fieldGroups` × field `group` — the fields decide which sections +// exist, `fieldGroups[].label` supplies the source text; +// (b) a named `sections[]` on a form view or inside a record page's +// component tree — where the nesting is deep enough +// (`page:tabs` → `properties.items[].children[]` → `record:details`) +// that a shallow walk sees nothing. + +import { describe, it, expect } from 'vitest'; +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage'; +import { foldCoverageIssues } from '../src/commands/lint'; +import { ObjectTranslationDataSchema } from '@objectstack/spec/system'; +import { deriveFieldGroupLayout } from '@objectstack/spec/data'; + +/** Every `objects.._sections.*` path the walker emits, as dot-paths. */ +const sectionKeys = (config: any) => + collectExpectedEntries(config) + .filter((e) => e.source === 'section') + .map((e) => e.path.join('.')); + +const sectionEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.source === 'section'); + +// ── (a) fieldGroups × field `group` ──────────────────────────────────── + +/** + * `showcase_semantic_zoo`'s posture: two declared groups, both referenced. + * `money` also carries icon/description, so the group is the full ADR-0085 + * shape rather than a bare key. + */ +const groupedObject = { + name: 'crm_opportunity', + label: 'Opportunity', + fields: { + name: { label: 'Name', group: 'basics' }, + amount: { label: 'Amount', group: 'money' }, + notes: { label: 'Notes' }, + }, + fieldGroups: [ + { key: 'basics', label: 'Basic Information' }, + { key: 'money', label: 'Financials', collapse: 'collapsed' }, + ], +}; + +describe('field-group sections', () => { + it('emits one label key per rendered group, seeded from `fieldGroups[].label`', () => { + const entries = sectionEntries({ objects: [groupedObject] }); + + expect(entries.map((e) => ({ path: e.path.join('.'), inline: e.inline, objectName: e.objectName }))).toEqual([ + { + path: 'objects.crm_opportunity._sections.basics.label', + inline: 'Basic Information', + objectName: 'crm_opportunity', + }, + { + path: 'objects.crm_opportunity._sections.money.label', + inline: 'Financials', + objectName: 'crm_opportunity', + }, + ]); + }); + + it('leaves the ungrouped bucket alone — it renders without a heading', () => { + // `notes` declares no group and lands in the trailing untitled bucket, + // which `deriveFieldGroupLayout` returns with NO `key`. Renderers show no + // card chrome for it, so there is no heading to translate. + expect(sectionKeys({ objects: [groupedObject] })).not.toContain( + 'objects.crm_opportunity._sections.undefined.label', + ); + expect(sectionKeys({ objects: [groupedObject] })).toHaveLength(2); + }); + + it('drops a declared group no visible field references', () => { + // The fields are the authority for which sections EXIST — a group nothing + // references never becomes a heading, so demanding a translation for it + // would be a gap that cannot be seen on any screen. + const config = { + objects: [ + { + name: 'crm_lead', + fields: { name: { label: 'Name', group: 'basics' }, hidden_note: { label: 'Note', group: 'ghost', hidden: true } }, + fieldGroups: [{ key: 'basics', label: 'Basics' }, { key: 'ghost', label: 'Ghost' }], + }, + ], + }; + expect(sectionKeys(config)).toEqual(['objects.crm_lead._sections.basics.label']); + }); + + it('drops a field `group` that no `fieldGroups` entry declares', () => { + // `deriveFieldGroupLayout` treats an undeclared group as ungrouped, so + // nothing renders a heading for it. Same reasoning, other direction. + const config = { + objects: [ + { + name: 'crm_lead', + fields: { name: { label: 'Name', group: 'basics' }, phone: { label: 'Phone', group: 'nowhere' } }, + fieldGroups: [{ key: 'basics', label: 'Basics' }], + }, + ], + }; + expect(sectionKeys(config)).toEqual(['objects.crm_lead._sections.basics.label']); + }); + + it('keeps `inline` unset when the group declares no label of its own', () => { + // The key still renders (the derivation falls back to the group key), so + // the skeleton is seeded — but nobody AUTHORED that text, and the coverage + // gate must not report an untranslated string that does not exist. + const entries = sectionEntries({ + objects: [ + { + name: 'crm_lead', + fields: { name: { label: 'Name', group: 'basics' } }, + fieldGroups: [{ key: 'basics' }], + }, + ], + }); + expect(entries).toEqual([ + { + path: ['objects', 'crm_lead', '_sections', 'basics', 'label'], + sourceValue: 'basics', + inline: undefined, + source: 'section', + objectName: 'crm_lead', + }, + ]); + }); +}); + +// ── (b) authored sections on form views and record pages ─────────────── + +describe('authored form-view sections', () => { + const config = { + views: [ + { + list: { type: 'grid', data: { object: 'crm_contact' } }, + form: { + type: 'simple', + data: { object: 'crm_contact' }, + sections: [ + { name: 'contact', label: 'Contact', fields: ['name'] }, + { name: 'work', label: 'Work', fields: ['company'] }, + ], + }, + formViews: { + create: { + type: 'simple', + data: { object: 'crm_contact' }, + sections: [ + { name: 'who', label: 'Who is this?', fields: ['name'] }, + // No `name` → `ObjectForm`'s `tSec` falls straight through to + // `s.label`; the lookup can never fire, so no expected key. + { label: 'Anything else?', fields: ['notes'] }, + ], + }, + }, + }, + ], + }; + + it('covers the container default form AND its named overrides', () => { + expect(sectionKeys(config).sort()).toEqual([ + 'objects.crm_contact._sections.contact.label', + 'objects.crm_contact._sections.who.label', + 'objects.crm_contact._sections.work.label', + ]); + }); + + it('skips a section with no `name` — every renderer guards the lookup on it', () => { + expect(sectionKeys(config)).not.toContain('objects.crm_contact._sections.anything_else.label'); + }); +}); + +describe('authored record-page sections', () => { + /** + * The showcase `showcase_project_detail` shape: a slotted record page whose + * `record:details` lives two levels down inside `page:tabs` + * (`properties.items[].children[]`). This is the nesting the issue calls + * out — a walk that only looked at `regions[].components[].properties` + * would report nothing here. + */ + const slottedPage = { + name: 'crm_opportunity_detail', + type: 'record', + object: 'crm_opportunity', + kind: 'slotted', + regions: [], + slots: { + tabs: { + type: 'page:tabs', + properties: { + items: [ + { + key: 'details', + label: 'Details', + children: [ + { + type: 'record:details', + properties: { + sections: [ + { name: 'overview', label: 'Overview', fields: ['name'] }, + { name: 'timeline', title: 'Timeline', fields: ['close_date'] }, + ], + }, + }, + ], + }, + ], + }, + }, + }, + }; + + it('reaches sections nested under `page:tabs → children → record:details`', () => { + expect(sectionKeys({ pages: [slottedPage] }).sort()).toEqual([ + 'objects.crm_opportunity._sections.overview.label', + 'objects.crm_opportunity._sections.timeline.label', + ]); + }); + + it('reads `title` as the source text too — that is what `record:details` renders', () => { + const timeline = sectionEntries({ pages: [slottedPage] }).find((e) => e.path[3] === 'timeline'); + expect(timeline?.inline).toBe('Timeline'); + }); + + 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 + // `regions[].components[]` / `slots.` and reads `sections` only from + // a COMPONENT's `properties`, so the region name `main` can never become a + // heading key. + // + // Paired with a real section in that same region, so the assertion cannot + // pass merely because nothing was produced. + const config = { + pages: [ + { + name: 'crm_lead_detail', + object: 'crm_lead', + regions: [ + { + name: 'main', + components: [ + { type: 'record:details', properties: { sections: [{ name: 'summary', label: 'Summary' }] } }, + ], + }, + ], + }, + ], + }; + const keys = sectionKeys(config); + expect(keys).toEqual(['objects.crm_lead._sections.summary.label']); + expect(keys).not.toContain('objects.crm_lead._sections.main.label'); + }); + + it('ignores a page with no object binding — the key has nothing to hang on', () => { + const detailsComponent = { + type: 'record:details', + properties: { sections: [{ name: 'summary', label: 'Summary' }] }, + }; + const config = { + pages: [ + // No `object`: the section is real but there is no object to key it + // under, so no expected key can be formed. + { name: 'marketing_home', regions: [{ name: 'main', components: [detailsComponent] }] }, + // Same component, bound — proves the exclusion is the binding and not + // a dead walk. + { name: 'crm_lead_detail', object: 'crm_lead', regions: [{ name: 'main', components: [detailsComponent] }] }, + ], + }; + expect(sectionKeys(config)).toEqual(['objects.crm_lead._sections.summary.label']); + }); + + it('keys a re-bound component under ITS object, not the page’s', () => { + // `dataSource.object` / `properties.object` retarget a component, so one + // page can show two objects. Keying such a section under the PAGE's object + // is worse than missing it — the bundle entry would sit at a path the + // resolver never reads. The shared lint traversal resolves the binding per + // component, which is the reason to reuse it rather than re-walk here. + const config = { + pages: [ + { + name: 'crm_lead_detail', + object: 'crm_lead', + regions: [ + { + name: 'main', + components: [ + { + type: 'record:details', + dataSource: { object: 'crm_account' }, + properties: { sections: [{ name: 'company', label: 'Company' }] }, + }, + ], + }, + ], + }, + ], + }; + expect(sectionKeys(config)).toEqual(['objects.crm_account._sections.company.label']); + }); + + it('skips a source-authored page — its `regions` are a derived cache, not authoring', () => { + // `kind: 'html' | 'react' | 'jsx'` pages are authored as `source`; the + // region tree is at most a compiled cache the source wins over. Scaffolding + // translation keys off it would invent an authoring surface, so the shared + // walk yields nothing for those pages. Paired with the same component on a + // normally-authored page. + const detailsComponent = { + type: 'record:details', + properties: { sections: [{ name: 'summary', label: 'Summary' }] }, + }; + const config = { + pages: [ + { name: 'crm_lead_html', object: 'crm_lead', kind: 'html', source: '
', regions: [{ name: 'main', components: [detailsComponent] }] }, + { name: 'crm_account_detail', object: 'crm_account', regions: [{ name: 'main', components: [detailsComponent] }] }, + ], + }; + expect(sectionKeys(config)).toEqual(['objects.crm_account._sections.summary.label']); + }); +}); + +describe('one key per (object, section) however many surfaces declare it', () => { + it('does not double-count a heading authored as a field group AND a page section', () => { + const config = { + objects: [groupedObject], + pages: [ + { + name: 'crm_opportunity_detail', + object: 'crm_opportunity', + regions: [ + { + name: 'main', + components: [ + { type: 'record:details', properties: { sections: [{ name: 'basics', label: 'Basic Information' }] } }, + ], + }, + ], + }, + ], + }; + expect(sectionKeys(config)).toEqual([ + 'objects.crm_opportunity._sections.basics.label', + 'objects.crm_opportunity._sections.money.label', + ]); + }); + + it('takes the authored heading from whichever surface has one', () => { + // The field group declares no `label`, the page section does. Whichever + // surface is walked first, `inline` has to report the text the reader + // actually sees — otherwise the gate stays quiet about a heading that is + // sitting on screen in the source locale. + const config = { + objects: [ + { + name: 'crm_lead', + fields: { name: { label: 'Name', group: 'basics' } }, + fieldGroups: [{ key: 'basics' }], + }, + ], + pages: [ + { + name: 'crm_lead_detail', + object: 'crm_lead', + regions: [ + { + name: 'main', + components: [ + { type: 'record:details', properties: { sections: [{ name: 'basics', label: 'Basic Information' }] } }, + ], + }, + ], + }, + ], + }; + const entries = sectionEntries(config); + expect(entries).toHaveLength(1); + expect(entries[0].inline).toBe('Basic Information'); + }); +}); + +// ── The gate: lint reports it, extract scaffolds it ──────────────────── + +describe('coverage + lint', () => { + const config = { + objects: [groupedObject], + i18n: { defaultLocale: 'en', supportedLocales: ['ja-JP'] }, + translations: [ + { + 'ja-JP': { + objects: { + crm_opportunity: { + label: '商談', + fields: { name: { label: '名前' }, amount: { label: '金額' }, notes: { label: 'メモ' } }, + // `basics` is translated; `money` is not — exactly the hotcrm + // shape where a bundle looks complete and the headings are not. + _sections: { basics: { label: '基本情報' } }, + }, + }, + }, + }, + ], + }; + + it('reports the untranslated heading that used to be invisible', () => { + const report = computeI18nCoverage(config); + const sections = report.issues.filter((i) => i.source === 'section'); + + expect(sections.map((i) => ({ locale: i.locale, key: i.key, severity: i.severity }))).toEqual([ + { + locale: 'ja-JP', + key: 'objects.crm_opportunity._sections.money.label', + severity: 'warning', + }, + ]); + expect(sections[0].message).toContain('Section "crm_opportunity" _sections.money.label'); + }); + + it('surfaces it through `os lint` as `i18n/missing-section`, not as platform noise', () => { + const { folded, hiddenPlatform } = foldCoverageIssues( + computeI18nCoverage(config).issues, + /* includePlatform */ false, + ); + const rules = folded.filter((i) => i.rule.startsWith('i18n/')).map((i) => i.rule); + + expect(rules).toContain('i18n/missing-section'); + // The heading belongs to the user's own metadata: `--include-platform` + // must not be what makes it visible. + expect(hiddenPlatform).toBeGreaterThan(0); + expect( + foldCoverageIssues(computeI18nCoverage(config).issues, false).folded.some( + (i) => i.path === 'translations.ja-JP.objects.crm_opportunity._sections.money.label', + ), + ).toBe(true); + }); + + it('scaffolds the heading in `os i18n extract` — the same walker, for free', () => { + const result = extractTranslations({ objects: [groupedObject] }, { locales: ['ja-JP'], fill: 'default' }); + expect((result.bundles['ja-JP'] as any).objects.crm_opportunity._sections).toEqual({ + basics: { label: 'Basic Information' }, + money: { label: 'Financials' }, + }); + }); + + it('a monolingual project still reports nothing', () => { + // The gate is opt-in by construction and sections must not break that. + const report = computeI18nCoverage({ objects: [groupedObject] }); + expect(report.issues.filter((i) => i.source === 'section')).toEqual([]); + }); +}); + +// ── Reconciliation: declared = enforced ──────────────────────────────── + +describe('the emitted key is the key the platform declares and consumes', () => { + it('every emitted path parses against `ObjectTranslationDataSchema._sections`', () => { + // The DECLARATION face. `packages/spec/src/system/translation.zod.ts` + // declares `_sections: z.record(..., { label, description })` on a + // strictObject — so a walker that invented a spelling (`sections`, + // `_section`, `.title`) would be rejected here rather than silently + // scaffolding keys no bundle may legally carry. + const bundle = extractTranslations({ objects: [groupedObject] }, { locales: ['en'] }) + .bundles.en as any; + + const parsed = ObjectTranslationDataSchema.safeParse(bundle.objects.crm_opportunity); + expect(parsed.success ? null : parsed.error.issues).toBeNull(); + expect(Object.keys((parsed as any).data._sections)).toEqual(['basics', 'money']); + }); + + it('field-group keys are read through the shared ADR-0085 derivation, not a second copy', () => { + // The CONSUMPTION face. `deriveFieldGroupLayout` is the one derivation the + // renderers consume — `@object-ui/plugin-detail`'s + // `deriveFieldGroupDetailSections` maps its `key` straight onto the + // section `name` that `sectionLabel(object, name, …)` looks up. Deriving + // the expected set the same way is what makes "declared" and "gated" the + // same set instead of two lists that drift. + const rendered = (deriveFieldGroupLayout(groupedObject) ?? []) + .filter((s) => s.key !== undefined) + .map((s) => `objects.crm_opportunity._sections.${s.key}.label`); + + expect(sectionKeys({ objects: [groupedObject] })).toEqual(rendered); + }); +}); + +// ── Real metadata from the in-repo example app ───────────────────────── + +describe('the showcase app, walked for real', () => { + it('picks up `showcase_contact`’s default-form sections and `showcase_semantic_zoo`’s field groups', async () => { + const [{ ContactViews }, { SemanticZoo }] = await Promise.all([ + import('../../../examples/app-showcase/src/ui/views/contact.view'), + import('../../../examples/app-showcase/src/data/objects/semantic-zoo.object'), + ]); + + const keys = sectionKeys({ objects: [SemanticZoo], views: [ContactViews] }); + + expect(keys.sort()).toEqual([ + // `fieldGroups: [{ key: 'basics' }, { key: 'money' }]`, both referenced + // by `group:` on real fields. + 'objects.showcase_semantic_zoo._sections.basics.label', + 'objects.showcase_semantic_zoo._sections.money.label', + // `ContactViews.form.sections[].name` — the container's DEFAULT form, + // which `ObjectForm` renders and translates like any other. + 'objects.showcase_contact._sections.contact.label', + 'objects.showcase_contact._sections.notes.label', + 'objects.showcase_contact._sections.status.label', + 'objects.showcase_contact._sections.work.label', + ].sort()); + }, 60_000); +}); diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 2dc7a20039..bc884b57c2 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -507,3 +507,11 @@ export { stackKeyForType, } from './runtime-gate.js'; export type { RuntimeGateResult, RuntimeStackContext } from './runtime-gate.js'; + +// The shared page-component traversal every `properties`-inspecting rule is +// built on (#3583). Exported because the CLI's i18n walker needs the same +// traversal to find `record:details` sections — and this walk is the one whose +// duplication has already produced a dead rule, so a second copy in another +// package is exactly what the module exists to prevent (#5405). +export { walkPageComponents, isSourceAuthoredPage } from './page-walk.js'; +export type { WalkedComponent } from './page-walk.js'; From e3d1e855cf99b66fded3a65c0116f3f18dfbbfb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 11:07:18 +0000 Subject: [PATCH 2/2] =?UTF-8?q?i18n(showcase):=20=E7=BF=BB=E8=AF=91=20walk?= =?UTF-8?q?er=20=E6=96=B0=E9=87=87=E5=87=BA=E7=9A=84=2010=20=E4=B8=AA?= =?UTF-8?q?=E6=AE=B5=E8=90=BD=E6=A0=87=E9=A2=98=20(#5405)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:i18n-coverage`(仓库级棘轮门)在 CI 抓到 showcase 未译声明串 451 → 461:新增的 `section` kind 把段落标题纳入覆盖面后,showcase 声明支持 zh-CN 却没有这些译文。门要的是译文,不是豁免 —— 基线与 `supportedLocales` 均未改动。 zh-CN 新增 10 条,均为既有体例: showcase_task._sections —— task.view.ts 三个表单投影的段落名 overview 概览 / schedule 排期 / details 详细信息 step_basics 基本信息 / step_assign 指派 / step_schedule 排期 split_task 任务 / split_schedule 排期 showcase_semantic_zoo._sections —— ADR-0085 fixture 的两个 fieldGroups basics 基本信息 / money 财务信息 后者按 `showcase_inquiry` / `showcase_business_unit` 的先例,只加 `_sections` 块(该对象本无 zh-CN 块,其余债务留在冻结基线内)。 `money` 取「财务信息」以贴合分组自己的 description(Financial fields),而非直译 Money。 `en` 不加条目:默认语言由内联 label 满足,复述源串只会伪造覆盖率。 check-i18n-coverage: OK (12 config(s), 660 baselined untranslated string(s), none new) —— showcase 精确回到 451,零 missing-section。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016FNvXhtSdnEGEfLEsMmvxh --- .../src/system/translations/index.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index b6d39f2dda..ae6eb597b5 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -251,6 +251,27 @@ export const ShowcaseTranslationBundle = { successMessage: '已为整个选中集重算工时。', }, }, + // Section headings of the four form-view projections in + // `ui/views/task.view.ts` (tabbed / wizard / split). Each section + // there declares a stable `name`, which is the only thing that makes + // the heading translatable — `ObjectForm` looks it up as + // `objects.showcase_task._sections..label` and otherwise renders + // the English `label` verbatim. Wording follows the vocabulary the + // rest of this bundle already uses (任务 / 负责人 / 进度), rather than + // introducing a second word per idea. + _sections: { + // tabbed + overview: { label: '概览' }, + schedule: { label: '排期' }, + details: { label: '详细信息' }, + // wizard steps + step_basics: { label: '基本信息' }, + step_assign: { label: '指派' }, + step_schedule: { label: '排期' }, + // split panes + split_task: { label: '任务' }, + split_schedule: { label: '排期' }, + }, }, showcase_account: { label: '客户', @@ -343,6 +364,19 @@ export const ShowcaseTranslationBundle = { org_chart: { label: '组织架构图' }, }, }, + // `_sections` only, on the same footing as the two `_views`-only blocks + // above: this ADR-0085 fixture has no zh-CN block of its own and its + // object/field debt sits inside the ratchet's baseline. Its two + // `fieldGroups` headings are what the coverage walker newly surfaces + // (#5405), and translating exactly those is what keeps the gate from + // widening; the rest is left exactly as it was. `财务信息` follows the + // group's own description ("Financial fields"), not the bare word Money. + showcase_semantic_zoo: { + _sections: { + basics: { label: '基本信息' }, + money: { label: '财务信息' }, + }, + }, showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', fields: { f_lookups: { label: '查找 → 客户(多值)' } },