From 1f976b75ec8eea43bd2c78144ac09a61253acee8 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 18:48:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(cli,spec):=20i18n=20coverage=20gates=20view?= =?UTF-8?q?=20labels=20=E2=80=94=20the=20defineView()=20container=20is=20n?= =?UTF-8?q?o=20longer=20skipped=20(#4123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `i18n/missing-view` had ZERO producers. The `'view'` source exists in `CoverageIssue`, the walker has code for it, and check-i18n-coverage's header claims listViews is covered — but no view label in this repo had ever been gated, and the ratchet reported green the whole time. `collectExpectedEntries` recognized two view shapes; the compiled config is neither: 1. Object-nested `listViews` — objects do not carry `listViews` once compiled (0 across every example; only hand-authored platform objects use that shape). 2. Top-level NAMED views — guarded by `if (!view?.name) continue`. `defineView()` emits the aggregated View CONTAINER — `{ list, listViews, formViews }` — which per spec (`view.zod.ts`) has no top-level `name`: it is keyed implicitly by its target object at `list.data.object`, exactly as objectql's `resolveMetadataItemName` resolves it. The guard therefore rejected the spec's own container shape, i.e. every view every example declares. Measured on the real compiled configs, same commit, before → after: app-crm total 1402 → 1420 | view 0 → 18 app-showcase total 2210 → 2250 | view 0 → 40 app-todo total 1343 → 1349 | view 0 → 6 No other source moved; the totals rise by exactly the view counts. The container walk emits under the same `objects.._views..*` convention the runtime resolver reads (`viewLabel` in @object-ui/i18n) and the shipped platform bundles already carry (`sys_user._views.all_users.label`). An unnamed default `list` resolves under `_views.list`, matching the console's `primary.name || 'list'`. A named list view may retarget another object via its own `data`, so its own object wins over the container's. `formViews` stays uncovered on purpose: form views have no counterpart in that resolver convention, so emitting keys for them would expect translations nothing reads. `StrictObjectTranslation` gains the `_views` slot `ObjectTranslationDataSchema` already permits — it is not derivable from the object type (views are declared separately), so it is optional and loosely keyed rather than enumerated like `fields`. Without it, `satisfies StrictObjectTranslation<…>` rejects the very translations the gate now asks for (app-todo). The 35 newly surfaced strings are TRANSLATED, not ratcheted — the precedent from the object-less action: raising the baseline makes the gate green by widening the debt it exists to prevent. Verified the gate is genuinely live on view keys rather than green by accident: deleting one view translation moves app-todo 120 → 121 and fails; restoring it returns to OK. check-i18n-coverage: 665, none new. os lint 0 errors on all three examples. spec 7022 tests, cli 877 tests, showcase 60, crm 20, todo 78. tsc + eslint clean. Co-Authored-By: Claude Opus 5 --- .changeset/i18n-view-coverage.md | 37 ++++++++ .../src/translations/crm.translation.ts | 15 ++++ .../src/system/translations/index.ts | 37 ++++++++ examples/app-todo/src/translations/ja-JP.ts | 5 ++ examples/app-todo/src/translations/zh-CN.ts | 5 ++ packages/cli/src/utils/i18n-extract.ts | 84 ++++++++++++++++--- .../spec/src/system/translation-typegen.ts | 16 ++++ 7 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 .changeset/i18n-view-coverage.md diff --git a/.changeset/i18n-view-coverage.md b/.changeset/i18n-view-coverage.md new file mode 100644 index 0000000000..d3e982a934 --- /dev/null +++ b/.changeset/i18n-view-coverage.md @@ -0,0 +1,37 @@ +--- +"@objectstack/cli": patch +"@objectstack/spec": patch +--- + +fix(cli,spec): i18n coverage actually gates view labels — the `defineView()` container is no longer skipped (#4123) + +`i18n/missing-view` had **zero producers**. `collectExpectedEntries` recognized +two view shapes and the compiled config is neither: + +1. **Object-nested `listViews`** — objects do not carry `listViews` once + compiled (0 across every example). +2. **Top-level named views** — guarded by `if (!view?.name) continue`. + +`defineView()` emits the aggregated View **container**, `{ list, listViews, +formViews }`, which per spec (`view.zod.ts`) has **no top-level `name`**: it is +keyed implicitly by its target object at `list.data.object`, exactly as +objectql's `resolveMetadataItemName` resolves it. So the guard rejected the +spec's own container shape, and with it every view in every example — 64 view +strings that the ratchet reported as fully covered. + +The walker now handles the container, emitting under the same +`objects.._views..*` convention the runtime resolver reads +(`viewLabel` in `@object-ui/i18n`) and the shipped platform bundles already +carry. An unnamed default `list` resolves under `_views.list`, matching the +console's `primary.name || 'list'`. `formViews` stays uncovered — form views +have no counterpart in that resolver convention, so keys for them would expect +translations nothing reads. + +`StrictObjectTranslation` gains the `_views` slot that +`ObjectTranslationDataSchema` already permits. Without it, `satisfies +StrictObjectTranslation<…>` rejects the very translations the gate now asks +for. + +The newly surfaced strings are **translated, not ratcheted** (the precedent set +when the object-less action landed): `check-i18n-coverage` stays at 665 with +none new. diff --git a/examples/app-crm/src/translations/crm.translation.ts b/examples/app-crm/src/translations/crm.translation.ts index 2c30a4d761..7b27956961 100644 --- a/examples/app-crm/src/translations/crm.translation.ts +++ b/examples/app-crm/src/translations/crm.translation.ts @@ -126,6 +126,11 @@ export const CrmTranslationBundle = defineTranslationBundle({ discount_percent: { label: '折扣 (%)' }, owner_id: { label: '负责人' }, }, + _views: { + list: { label: '全部商机' }, + all: { label: '全部商机' }, + pipeline: { label: '商机看板' }, + }, }, crm_lead: { label: '线索', @@ -138,6 +143,11 @@ export const CrmTranslationBundle = defineTranslationBundle({ lead_score: { label: '线索评分' }, source: { label: '来源' }, }, + _views: { + list: { label: '全部线索' }, + all: { label: '全部线索' }, + pipeline: { label: '线索看板' }, + }, }, crm_activity: { label: '活动', @@ -150,6 +160,11 @@ export const CrmTranslationBundle = defineTranslationBundle({ contact: { label: '联系人' }, opportunity: { label: '商机' }, }, + _views: { + list: { label: '全部活动' }, + all: { label: '全部活动' }, + calendar: { label: '活动日历' }, + }, }, }, apps: { diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 445acb082b..bf99c36a04 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -178,6 +178,11 @@ export const ShowcaseTranslationBundle = { start_date: { label: '开始日期' }, end_date: { label: '结束日期' }, }, + _views: { + list: { label: '全部项目' }, + by_status: { label: '按状态' }, + budget_chart: { label: '按客户预算' }, + }, }, showcase_task: { label: '任务', @@ -211,6 +216,22 @@ export const ShowcaseTranslationBundle = { }, sync_error: { label: '同步错误' }, }, + _views: { + list: { label: '全部任务' }, + in_progress: { label: '进行中' }, + urgent: { label: '紧急' }, + done: { label: '已完成' }, + tabular: { label: '任务清单' }, + grid: { label: '表格' }, + board: { label: '看板' }, + cards: { label: '卡片' }, + calendar: { label: '日历' }, + timeline: { label: '活动时间线' }, + gantt: { label: '甘特图' }, + map: { label: '工作地点地图' }, + chart: { label: '工时按状态分布' }, + legacy_row_actions: { label: '旧式行操作' }, + }, }, showcase_account: { label: '客户', @@ -287,6 +308,22 @@ export const ShowcaseTranslationBundle = { label: '分类', pluralLabel: '分类', fields: { name: { label: '名称' }, parent: { label: '上级' }, color: { label: '颜色' }, sort_order: { label: '排序' } }, }, + // `_views` only: these two objects have no zh-CN block of their own, and + // their object/field debt is inside the ratchet's baseline. Translating + // the VIEW labels the coverage fix newly surfaces is what keeps the gate + // from widening; the rest is left exactly as it was. + showcase_inquiry: { + _views: { + list: { label: '客户询问' }, + triage: { label: '询问分流' }, + }, + }, + showcase_business_unit: { + _views: { + list: { label: '全部单元' }, + org_chart: { label: '组织架构图' }, + }, + }, showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', fields: { f_lookups: { label: '查找 → 客户(多值)' } }, diff --git a/examples/app-todo/src/translations/ja-JP.ts b/examples/app-todo/src/translations/ja-JP.ts index ca5a06d59f..2eb31e1885 100644 --- a/examples/app-todo/src/translations/ja-JP.ts +++ b/examples/app-todo/src/translations/ja-JP.ts @@ -78,6 +78,11 @@ export const jaJP: TranslationData = { notes: { label: 'メモ' }, category_color: { label: 'カテゴリ色' }, }, + _views: { + list: { label: 'すべてのタスク' }, + overdue: { label: '期限切れのタスク' }, + due_today: { label: '本日期限' }, + }, }, }, apps: { diff --git a/examples/app-todo/src/translations/zh-CN.ts b/examples/app-todo/src/translations/zh-CN.ts index 913bb18622..509365ba46 100644 --- a/examples/app-todo/src/translations/zh-CN.ts +++ b/examples/app-todo/src/translations/zh-CN.ts @@ -82,6 +82,11 @@ export const zhCN: TranslationData = { notes: { label: '备注' }, category_color: { label: '分类颜色' }, }, + _views: { + list: { label: '全部任务' }, + overdue: { label: '逾期任务' }, + due_today: { label: '今日到期' }, + }, } satisfies TaskTranslation, }, apps: { diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index de51f1e70f..a99a741393 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -152,8 +152,36 @@ export interface ExtractResult { // ─── Walk helpers ────────────────────────────────────────────────────── +/** + * The object a view binds to. + * + * The last two arms cover the aggregated View CONTAINER (`{ list, listViews, + * formViews }`), which per spec carries no object of its own and is keyed + * implicitly by its inner data source — the same fallback chain objectql's + * `resolveMetadataItemName` uses to register it. + */ function viewObjectName(view: any): string | undefined { - return view?.objectName ?? view?.object ?? view?.data?.object; + return ( + view?.objectName ?? + view?.object ?? + view?.data?.object ?? + view?.list?.data?.object ?? + view?.form?.data?.object + ); +} + +/** + * Emit label / description / emptyState for ONE view under + * `objects.._views..*` — the convention the runtime resolver + * reads (`viewLabel` / `viewDescription` / `viewEmptyState` in + * @object-ui/i18n) and the one the shipped platform bundles already carry + * (`en.objects.generated.ts`: `sys_user._views.all_users.label`). + */ +function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: string, view: any): void { + const root = ['objects', objectName, '_views', viewName]; + pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName }); + pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName }); + pushViewEmptyState(out, root, view, objectName); } /** @@ -367,10 +395,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { // Object-nested listViews (object-protocol view bundle). if (obj.listViews && typeof obj.listViews === 'object') { for (const [viewName, raw] of Object.entries(obj.listViews)) { - const view = raw ?? {}; - pushDerived(out, ['objects', objectName, '_views', viewName, 'label'], view.label ?? viewName, inlineText(view.label), 'view', { objectName }); - pushOptional(out, ['objects', objectName, '_views', viewName, 'description'], view.description, 'view', { objectName }); - pushViewEmptyState(out, ['objects', objectName, '_views', viewName], view, objectName); + pushViewEntries(out, objectName, viewName, raw ?? {}); } } @@ -389,15 +414,50 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] { } } - // ── Top-level views (legacy / cross-object) ────────────────────── + // ── Top-level views ────────────────────────────────────────────── + // Two shapes reach `config.views`, and only one of them has a `name`: + // + // 1. An independent ViewItem — carries a top-level `name` and binds to its + // object via `object`. + // + // 2. The aggregated View CONTAINER `defineView()` emits: + // `{ list, listViews, formViews }`. Per spec (`view.zod.ts`) it has NO + // top-level `name` — it is keyed implicitly by its target object, which + // lives at `list.data.object` (objectql's `resolveMetadataItemName` + // says the same). + // + // Guarding the loop on `view.name` therefore skipped every container, and a + // container is what `defineView()` — i.e. every example and every app that + // authors views this way — actually produces. Objects do not carry + // `listViews` once compiled either, so BOTH view branches were dead and + // `i18n/missing-view` had zero producers repo-wide while the ratchet + // reported green (#4123). + // + // `formViews` stays uncovered: form views have no counterpart in the + // `viewLabel` / `_views.*` resolver convention, so emitting keys for them + // would expect translations nothing reads. const views: any[] = Array.isArray(config?.views) ? config.views : []; for (const view of views) { - if (!view?.name) continue; - const objectName = viewObjectName(view); - if (!objectName) continue; - pushDerived(out, ['objects', objectName, '_views', view.name, 'label'], view.label ?? view.name, inlineText(view.label), 'view', { objectName }); - pushOptional(out, ['objects', objectName, '_views', view.name, 'description'], view.description, 'view', { objectName }); - pushViewEmptyState(out, ['objects', objectName, '_views', view.name], view, objectName); + const containerObject = viewObjectName(view); + if (!containerObject) continue; + + if (view.name) { + pushViewEntries(out, containerObject, view.name, view); + continue; + } + + // The container's default list. The console ids an unnamed default as + // `primary.name || 'list'` (app-shell `ObjectView`), so it resolves under + // `_views.list`. + if (view.list && typeof view.list === 'object') { + pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list); + } + if (view.listViews && typeof view.listViews === 'object') { + for (const [viewName, raw] of Object.entries(view.listViews)) { + // A named list view may retarget another object via its own `data`. + pushViewEntries(out, viewObjectName(raw) ?? containerObject, viewName, raw ?? {}); + } + } } // ── Top-level actions ──────────────────────────────────────────── diff --git a/packages/spec/src/system/translation-typegen.ts b/packages/spec/src/system/translation-typegen.ts index 57522f8202..2a6be66a43 100644 --- a/packages/spec/src/system/translation-typegen.ts +++ b/packages/spec/src/system/translation-typegen.ts @@ -107,4 +107,20 @@ export type StrictObjectTranslation; + /** + * View translations keyed by view name — the `_views` slot + * `ObjectTranslationDataSchema` already permits. Not derivable from `Obj` + * (views are declared separately from the object), so it stays optional and + * loosely keyed rather than enumerated like `fields`. Without it, `satisfies + * StrictObjectTranslation<…>` rejects the very translations the view + * coverage gate asks for. + */ + _views?: Record< + string, + { + label?: string; + description?: string; + emptyState?: { title?: string; message?: string }; + } + >; };