Skip to content

Commit 1f976b7

Browse files
os-zhuangclaude
andcommitted
fix(cli,spec): i18n coverage gates view labels — the defineView() container is no longer skipped (#4123)
`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.<object>._views.<view>.*` 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 <noreply@anthropic.com>
1 parent 554ff92 commit 1f976b7

7 files changed

Lines changed: 187 additions & 12 deletions

File tree

.changeset/i18n-view-coverage.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/spec": patch
4+
---
5+
6+
fix(cli,spec): i18n coverage actually gates view labels — the `defineView()` container is no longer skipped (#4123)
7+
8+
`i18n/missing-view` had **zero producers**. `collectExpectedEntries` recognized
9+
two view shapes and the compiled config is neither:
10+
11+
1. **Object-nested `listViews`** — objects do not carry `listViews` once
12+
compiled (0 across every example).
13+
2. **Top-level named views** — guarded by `if (!view?.name) continue`.
14+
15+
`defineView()` emits the aggregated View **container**, `{ list, listViews,
16+
formViews }`, which per spec (`view.zod.ts`) has **no top-level `name`**: it is
17+
keyed implicitly by its target object at `list.data.object`, exactly as
18+
objectql's `resolveMetadataItemName` resolves it. So the guard rejected the
19+
spec's own container shape, and with it every view in every example — 64 view
20+
strings that the ratchet reported as fully covered.
21+
22+
The walker now handles the container, emitting under the same
23+
`objects.<object>._views.<view>.*` convention the runtime resolver reads
24+
(`viewLabel` in `@object-ui/i18n`) and the shipped platform bundles already
25+
carry. An unnamed default `list` resolves under `_views.list`, matching the
26+
console's `primary.name || 'list'`. `formViews` stays uncovered — form views
27+
have no counterpart in that resolver convention, so keys for them would expect
28+
translations nothing reads.
29+
30+
`StrictObjectTranslation` gains the `_views` slot that
31+
`ObjectTranslationDataSchema` already permits. Without it, `satisfies
32+
StrictObjectTranslation<…>` rejects the very translations the gate now asks
33+
for.
34+
35+
The newly surfaced strings are **translated, not ratcheted** (the precedent set
36+
when the object-less action landed): `check-i18n-coverage` stays at 665 with
37+
none new.

examples/app-crm/src/translations/crm.translation.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
126126
discount_percent: { label: '折扣 (%)' },
127127
owner_id: { label: '负责人' },
128128
},
129+
_views: {
130+
list: { label: '全部商机' },
131+
all: { label: '全部商机' },
132+
pipeline: { label: '商机看板' },
133+
},
129134
},
130135
crm_lead: {
131136
label: '线索',
@@ -138,6 +143,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
138143
lead_score: { label: '线索评分' },
139144
source: { label: '来源' },
140145
},
146+
_views: {
147+
list: { label: '全部线索' },
148+
all: { label: '全部线索' },
149+
pipeline: { label: '线索看板' },
150+
},
141151
},
142152
crm_activity: {
143153
label: '活动',
@@ -150,6 +160,11 @@ export const CrmTranslationBundle = defineTranslationBundle({
150160
contact: { label: '联系人' },
151161
opportunity: { label: '商机' },
152162
},
163+
_views: {
164+
list: { label: '全部活动' },
165+
all: { label: '全部活动' },
166+
calendar: { label: '活动日历' },
167+
},
153168
},
154169
},
155170
apps: {

examples/app-showcase/src/system/translations/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ export const ShowcaseTranslationBundle = {
178178
start_date: { label: '开始日期' },
179179
end_date: { label: '结束日期' },
180180
},
181+
_views: {
182+
list: { label: '全部项目' },
183+
by_status: { label: '按状态' },
184+
budget_chart: { label: '按客户预算' },
185+
},
181186
},
182187
showcase_task: {
183188
label: '任务',
@@ -211,6 +216,22 @@ export const ShowcaseTranslationBundle = {
211216
},
212217
sync_error: { label: '同步错误' },
213218
},
219+
_views: {
220+
list: { label: '全部任务' },
221+
in_progress: { label: '进行中' },
222+
urgent: { label: '紧急' },
223+
done: { label: '已完成' },
224+
tabular: { label: '任务清单' },
225+
grid: { label: '表格' },
226+
board: { label: '看板' },
227+
cards: { label: '卡片' },
228+
calendar: { label: '日历' },
229+
timeline: { label: '活动时间线' },
230+
gantt: { label: '甘特图' },
231+
map: { label: '工作地点地图' },
232+
chart: { label: '工时按状态分布' },
233+
legacy_row_actions: { label: '旧式行操作' },
234+
},
214235
},
215236
showcase_account: {
216237
label: '客户',
@@ -287,6 +308,22 @@ export const ShowcaseTranslationBundle = {
287308
label: '分类', pluralLabel: '分类',
288309
fields: { name: { label: '名称' }, parent: { label: '上级' }, color: { label: '颜色' }, sort_order: { label: '排序' } },
289310
},
311+
// `_views` only: these two objects have no zh-CN block of their own, and
312+
// their object/field debt is inside the ratchet's baseline. Translating
313+
// the VIEW labels the coverage fix newly surfaces is what keeps the gate
314+
// from widening; the rest is left exactly as it was.
315+
showcase_inquiry: {
316+
_views: {
317+
list: { label: '客户询问' },
318+
triage: { label: '询问分流' },
319+
},
320+
},
321+
showcase_business_unit: {
322+
_views: {
323+
list: { label: '全部单元' },
324+
org_chart: { label: '组织架构图' },
325+
},
326+
},
290327
showcase_field_zoo: {
291328
label: '字段动物园', pluralLabel: '字段动物园',
292329
fields: { f_lookups: { label: '查找 → 客户(多值)' } },

examples/app-todo/src/translations/ja-JP.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export const jaJP: TranslationData = {
7878
notes: { label: 'メモ' },
7979
category_color: { label: 'カテゴリ色' },
8080
},
81+
_views: {
82+
list: { label: 'すべてのタスク' },
83+
overdue: { label: '期限切れのタスク' },
84+
due_today: { label: '本日期限' },
85+
},
8186
},
8287
},
8388
apps: {

examples/app-todo/src/translations/zh-CN.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ export const zhCN: TranslationData = {
8282
notes: { label: '备注' },
8383
category_color: { label: '分类颜色' },
8484
},
85+
_views: {
86+
list: { label: '全部任务' },
87+
overdue: { label: '逾期任务' },
88+
due_today: { label: '今日到期' },
89+
},
8590
} satisfies TaskTranslation,
8691
},
8792
apps: {

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

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,36 @@ export interface ExtractResult {
152152

153153
// ─── Walk helpers ──────────────────────────────────────────────────────
154154

155+
/**
156+
* The object a view binds to.
157+
*
158+
* The last two arms cover the aggregated View CONTAINER (`{ list, listViews,
159+
* formViews }`), which per spec carries no object of its own and is keyed
160+
* implicitly by its inner data source — the same fallback chain objectql's
161+
* `resolveMetadataItemName` uses to register it.
162+
*/
155163
function viewObjectName(view: any): string | undefined {
156-
return view?.objectName ?? view?.object ?? view?.data?.object;
164+
return (
165+
view?.objectName ??
166+
view?.object ??
167+
view?.data?.object ??
168+
view?.list?.data?.object ??
169+
view?.form?.data?.object
170+
);
171+
}
172+
173+
/**
174+
* Emit label / description / emptyState for ONE view under
175+
* `objects.<object>._views.<viewName>.*` — the convention the runtime resolver
176+
* reads (`viewLabel` / `viewDescription` / `viewEmptyState` in
177+
* @object-ui/i18n) and the one the shipped platform bundles already carry
178+
* (`en.objects.generated.ts`: `sys_user._views.all_users.label`).
179+
*/
180+
function pushViewEntries(out: ExpectedEntry[], objectName: string, viewName: string, view: any): void {
181+
const root = ['objects', objectName, '_views', viewName];
182+
pushDerived(out, [...root, 'label'], view?.label ?? viewName, inlineText(view?.label), 'view', { objectName });
183+
pushOptional(out, [...root, 'description'], view?.description, 'view', { objectName });
184+
pushViewEmptyState(out, root, view, objectName);
157185
}
158186

159187
/**
@@ -367,10 +395,7 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
367395
// Object-nested listViews (object-protocol view bundle).
368396
if (obj.listViews && typeof obj.listViews === 'object') {
369397
for (const [viewName, raw] of Object.entries<any>(obj.listViews)) {
370-
const view = raw ?? {};
371-
pushDerived(out, ['objects', objectName, '_views', viewName, 'label'], view.label ?? viewName, inlineText(view.label), 'view', { objectName });
372-
pushOptional(out, ['objects', objectName, '_views', viewName, 'description'], view.description, 'view', { objectName });
373-
pushViewEmptyState(out, ['objects', objectName, '_views', viewName], view, objectName);
398+
pushViewEntries(out, objectName, viewName, raw ?? {});
374399
}
375400
}
376401

@@ -389,15 +414,50 @@ export function collectExpectedEntries(config: any): ExpectedEntry[] {
389414
}
390415
}
391416

392-
// ── Top-level views (legacy / cross-object) ──────────────────────
417+
// ── Top-level views ──────────────────────────────────────────────
418+
// Two shapes reach `config.views`, and only one of them has a `name`:
419+
//
420+
// 1. An independent ViewItem — carries a top-level `name` and binds to its
421+
// object via `object`.
422+
//
423+
// 2. The aggregated View CONTAINER `defineView()` emits:
424+
// `{ list, listViews, formViews }`. Per spec (`view.zod.ts`) it has NO
425+
// top-level `name` — it is keyed implicitly by its target object, which
426+
// lives at `list.data.object` (objectql's `resolveMetadataItemName`
427+
// says the same).
428+
//
429+
// Guarding the loop on `view.name` therefore skipped every container, and a
430+
// container is what `defineView()` — i.e. every example and every app that
431+
// authors views this way — actually produces. Objects do not carry
432+
// `listViews` once compiled either, so BOTH view branches were dead and
433+
// `i18n/missing-view` had zero producers repo-wide while the ratchet
434+
// reported green (#4123).
435+
//
436+
// `formViews` stays uncovered: form views have no counterpart in the
437+
// `viewLabel` / `_views.*` resolver convention, so emitting keys for them
438+
// would expect translations nothing reads.
393439
const views: any[] = Array.isArray(config?.views) ? config.views : [];
394440
for (const view of views) {
395-
if (!view?.name) continue;
396-
const objectName = viewObjectName(view);
397-
if (!objectName) continue;
398-
pushDerived(out, ['objects', objectName, '_views', view.name, 'label'], view.label ?? view.name, inlineText(view.label), 'view', { objectName });
399-
pushOptional(out, ['objects', objectName, '_views', view.name, 'description'], view.description, 'view', { objectName });
400-
pushViewEmptyState(out, ['objects', objectName, '_views', view.name], view, objectName);
441+
const containerObject = viewObjectName(view);
442+
if (!containerObject) continue;
443+
444+
if (view.name) {
445+
pushViewEntries(out, containerObject, view.name, view);
446+
continue;
447+
}
448+
449+
// The container's default list. The console ids an unnamed default as
450+
// `primary.name || 'list'` (app-shell `ObjectView`), so it resolves under
451+
// `_views.list`.
452+
if (view.list && typeof view.list === 'object') {
453+
pushViewEntries(out, viewObjectName(view.list) ?? containerObject, view.list.name ?? 'list', view.list);
454+
}
455+
if (view.listViews && typeof view.listViews === 'object') {
456+
for (const [viewName, raw] of Object.entries<any>(view.listViews)) {
457+
// A named list view may retarget another object via its own `data`.
458+
pushViewEntries(out, viewObjectName(raw) ?? containerObject, viewName, raw ?? {});
459+
}
460+
}
401461
}
402462

403463
// ── Top-level actions ────────────────────────────────────────────

packages/spec/src/system/translation-typegen.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,20 @@ export type StrictObjectTranslation<Obj extends { fields: Record<string, unknown
107107
label: string;
108108
pluralLabel?: string;
109109
fields: StrictFieldTranslations<Obj['fields']>;
110+
/**
111+
* View translations keyed by view name — the `_views` slot
112+
* `ObjectTranslationDataSchema` already permits. Not derivable from `Obj`
113+
* (views are declared separately from the object), so it stays optional and
114+
* loosely keyed rather than enumerated like `fields`. Without it, `satisfies
115+
* StrictObjectTranslation<…>` rejects the very translations the view
116+
* coverage gate asks for.
117+
*/
118+
_views?: Record<
119+
string,
120+
{
121+
label?: string;
122+
description?: string;
123+
emptyState?: { title?: string; message?: string };
124+
}
125+
>;
110126
};

0 commit comments

Comments
 (0)