Skip to content

Commit 3d2c047

Browse files
committed
fix(lint): key a container's default list _views name by the runtime identity (#5164) (#6038)
`collectViewRecord` decided which `_views` name a container's default `list` makes legal by reading `view.list.name` — and registered NOTHING when the author wrote none. The composer that assigns the runtime view identity (`expandViewContainer`, `packages/spec/src/ui/view.zod.ts`) names that very same view `<object>.default`. Leg 1 (#6124) moved the i18n extractor onto the composer's key, which turned a latent disagreement into a live contradiction: in ONE `os lint` run `i18n/missing-view` demanded `objects.<object>._views.default.label` while `translation-target-unknown` called that key an orphan. Measured on main: 8 instances (app-showcase 6, app-todo 2) — no author action made both green. This rule now ASKS the composer for the key too, inheriting its three rules: a nameless default list is `default`; one that structurally restates a `listViews` entry is collapsed into it (so the collapsed-away `list.name` stops being legal — nothing resolves it); a collision-renamed key is judged under the rename, because the rename is the registry key. Measured across all 12 ratcheted configs: 0 new findings, 8 false positives gone, `check:i18n-coverage` baseline unchanged. Leg 2 of 3; objectui `viewSuffixes` is objectui#3502. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
1 parent e15bf7e commit 3d2c047

3 files changed

Lines changed: 293 additions & 3 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
`translation-target-unknown` 按运行时视图身份判定容器默认 `list``_views` 键(#5164 第 2 棒 / lint 段)
6+
7+
`validate-translation-references``collectViewRecord()` 过去读 `view.list.name`
8+
来决定容器默认列表贡献哪个 `_views` 名 —— 作者没写 `name` 时它什么也不注册,而组装器
9+
(`expandViewContainer`,`packages/spec/src/ui/view.zod.ts`)给同一个视图的身份是
10+
`<object>.default`。第 1 棒(#6124)已把 i18n 提取器改为向组装器查询同一个键,于是
11+
**同一次 `os lint` 运行里**出现了一对自相矛盾的结论:
12+
13+
- 要求方 `i18n/missing-view`:`objects.<object>._views.default.label` 缺翻译;
14+
- 否定方 `translation-target-unknown`:`_views.default` 是孤儿键,「no view of object
15+
`<object>` declares it」。
16+
17+
作者补了译文被判孤儿,删了译文被判缺翻译,两条都躲不掉。本仓库自带示例上实测有 8 处
18+
(`examples/app-showcase` 6 处、`examples/app-todo` 2 处)。
19+
20+
本规则现在同样**向组装器查询**这个键,而不是第三次自行推导,因此继承了组装器仅有的三条
21+
规则:
22+
23+
-`name` 的默认列表键为 `default`;带 `name` 的沿用作者的 `name`;
24+
- 结构上与某个 `listViews` 条目完全相同的默认列表被组装器按签名**折叠**进该条目,只有
25+
存活的那个键合法 —— 被折叠掉的 `list.name` 不再是合法键(`examples/app-crm` 形状);
26+
- 因命名冲突被改名的键(`default``default_2`)按**改名后**判定,因为改名后的名字才是
27+
注册表键。
28+
29+
## 判定变化(全是 warning,不改 `os lint` 退出码)
30+
31+
| 形状 | 变化前 | 变化后 |
32+
|---|---|---|
33+
| 默认 `list``name`,包里写 `_views.default.*` | 报孤儿(误报) | 通过 |
34+
| 默认 `list``name`,包里写 `_views.list.*` | 报孤儿 | 报孤儿(不变;提示语现在会列出 `default`) |
35+
| 默认 `list` 与某个 `listViews.<k>` 同签名,包里写 `_views.<list.name>.*` | 通过(漏报) | 报孤儿 —— 该键运行时解析不到 |
36+
| 默认 `list` 因冲突被改名 `default_2`,包里写 `_views.default_2.*` | 报孤儿(误报) | 通过 |
37+
38+
本仓库 12 个受棘轮覆盖的配置上实测:**新增 0 条**,消除 8 条误报;
39+
`check:i18n-coverage` 基线不变(该棘轮只数 `i18n/` 前缀,本规则不在其内)。
40+
41+
裁决依据:维护者 2026-08-06(#5164)—— `_views` 翻译键的 canonical 拼写 = 运行时身份的
42+
裸键。第 3 棒 objectui `viewSuffixes` 去第二候选(objectui#3502)不在本次变更内。

packages/lint/src/validate-translation-references.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,13 +411,21 @@ describe('validateTranslationReferences — the canonical view-record shape', ()
411411
// Reading `view.name` / `view.data.object` at the record root resolves
412412
// nothing here, drops the record, and reports every view key the app ships —
413413
// ~40 correct keys on the real corpus.
414+
//
415+
// The default list carries a `label` (#6038): without one it is
416+
// signature-identical to `listViews.my_leads` (`{type,label,columns}` all
417+
// equal), the composer collapses the two, and `all_leads` is not a runtime
418+
// view name at all — so the fixture would be asserting that a key nothing
419+
// resolves is legal. The label makes it the distinct default list this test
420+
// says it is. The collapse itself is pinned separately below.
414421
const leadViews = {
415422
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
416423
views: [
417424
{
418425
list: {
419426
type: 'grid',
420427
name: 'all_leads',
428+
label: 'All Leads',
421429
data: { provider: 'object', object: 'crm_lead' },
422430
},
423431
listViews: {
@@ -472,6 +480,159 @@ describe('validateTranslationReferences — the canonical view-record shape', ()
472480
expect(findings[0].hint).toContain('all_leads');
473481
});
474482

483+
// ── #6038 / #5164 leg 2: the default list's key is the RUNTIME's ─────────
484+
//
485+
// The composer (`expandViewContainer`) is the single producer of a view's
486+
// runtime identity, and these pin that this rule reads the key from it
487+
// instead of re-deriving one. Every fixture below is driven through the real
488+
// `validateTranslationReferences`, and every "legal" assertion is paired with
489+
// a planted bad key on the SAME fixture — a `toEqual([])` that passes because
490+
// the rule produced nothing at all would prove nothing.
491+
describe('the default list is keyed by the runtime identity, single spelling', () => {
492+
/** The showcase shape: a container declaring ONLY a nameless default list. */
493+
const namelessDefaultList = {
494+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
495+
views: [
496+
{
497+
list: { type: 'grid', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } },
498+
},
499+
],
500+
};
501+
502+
const bundle = (views: Record<string, unknown>) => ({
503+
translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: views } } } }],
504+
});
505+
506+
it('accepts `default` for a nameless default list — the key the registry holds', () => {
507+
const findings = validateTranslationReferences({
508+
...namelessDefaultList,
509+
...bundle({ default: { label: '全部线索' } }),
510+
});
511+
expect(findings).toEqual([]);
512+
});
513+
514+
it('the same fixture still reports a key nothing declares (the green above is not an empty run)', () => {
515+
const findings = validateTranslationReferences({
516+
...namelessDefaultList,
517+
...bundle({ default: { label: '全部线索' }, hot_leads: { label: 'Hot' } }),
518+
});
519+
expect(findings).toHaveLength(1);
520+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.hot_leads');
521+
});
522+
523+
it('rejects the old `list` spelling — one key per view, and it is the runtime one', () => {
524+
const findings = validateTranslationReferences({
525+
...namelessDefaultList,
526+
...bundle({ list: { label: '全部线索' } }),
527+
});
528+
expect(findings).toHaveLength(1);
529+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.list');
530+
expect(findings[0].hint).toContain('default');
531+
});
532+
533+
it('a named default list keeps the author\'s `name`', () => {
534+
const stack = {
535+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
536+
views: [
537+
{
538+
list: {
539+
type: 'grid',
540+
name: 'all_leads',
541+
label: 'All Leads',
542+
data: { provider: 'object', object: 'crm_lead' },
543+
},
544+
listViews: { my_leads: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } } },
545+
},
546+
],
547+
};
548+
expect(
549+
validateTranslationReferences({ ...stack, ...bundle({ all_leads: { label: 'A' }, my_leads: { label: 'M' } }) }),
550+
).toEqual([]);
551+
// …and `default` is NOT legal here: the author named the view, so the
552+
// composer never falls back to `default`.
553+
const planted = validateTranslationReferences({ ...stack, ...bundle({ default: { label: 'D' } }) });
554+
expect(planted).toHaveLength(1);
555+
expect(planted[0].path).toBe('translations[0].en.objects.crm_lead._views.default');
556+
});
557+
558+
it('a default list collapsed into a `listViews` entry contributes that entry\'s key, not its own `name`', () => {
559+
// Composer fact 2 — the `examples/app-crm` shape: `list` is
560+
// signature-identical to `listViews.all` (`{type,label,columns}` equal),
561+
// so the two are ONE registry entry named `all`. `list.name` resolves to
562+
// nothing and must not be a legal bundle key.
563+
const collapsed = {
564+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
565+
views: [
566+
{
567+
list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } },
568+
listViews: { all: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } } },
569+
},
570+
],
571+
};
572+
expect(validateTranslationReferences({ ...collapsed, ...bundle({ all: { label: '全部' } }) })).toEqual([]);
573+
const findings = validateTranslationReferences({ ...collapsed, ...bundle({ all_leads: { label: '全部' } }) });
574+
expect(findings).toHaveLength(1);
575+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.all_leads');
576+
});
577+
578+
it('a collision-renamed default list is legal under the renamed key', () => {
579+
// Composer fact 3: `listViews.default` claims `crm_lead.default` first,
580+
// so the nameless default list is renamed `crm_lead.default_2` — and the
581+
// rename IS the registry key, so it is what a bundle must spell.
582+
const collided = {
583+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
584+
views: [
585+
{
586+
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } },
587+
listViews: { default: { type: 'kanban', data: { provider: 'object', object: 'crm_lead' } } },
588+
},
589+
],
590+
};
591+
expect(
592+
validateTranslationReferences({ ...collided, ...bundle({ default: { label: 'D' }, default_2: { label: 'D2' } }) }),
593+
).toEqual([]);
594+
const findings = validateTranslationReferences({ ...collided, ...bundle({ default_3: { label: 'D3' } }) });
595+
expect(findings).toHaveLength(1);
596+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.default_3');
597+
});
598+
599+
it('the default FORM contributes sections but no `_views` name — `_views.*` is a list convention', () => {
600+
// The composer does name the default form `crm_lead.form`, but the i18n
601+
// walker emits no `_views` entry for any form view, so a `_views.form`
602+
// key would be one nothing reads. Its `_sections` still resolve (#5415).
603+
const withForm = {
604+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
605+
views: [
606+
{
607+
list: { type: 'grid', label: 'All', data: { provider: 'object', object: 'crm_lead' } },
608+
form: {
609+
type: 'simple',
610+
data: { provider: 'object', object: 'crm_lead' },
611+
sections: [{ name: 'contact_info', label: 'Contact Info' }],
612+
},
613+
},
614+
],
615+
};
616+
expect(
617+
validateTranslationReferences({
618+
...withForm,
619+
translations: [
620+
{
621+
en: {
622+
objects: {
623+
crm_lead: { label: 'Lead', _views: { default: { label: 'All' } }, _sections: { contact_info: { label: '联系方式' } } },
624+
},
625+
},
626+
},
627+
],
628+
}),
629+
).toEqual([]);
630+
const findings = validateTranslationReferences({ ...withForm, ...bundle({ form: { label: 'Form' } }) });
631+
expect(findings).toHaveLength(1);
632+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.form');
633+
});
634+
});
635+
475636
it('resolves views embedded on the object itself', () => {
476637
const findings = validateTranslationReferences({
477638
objects: [
@@ -646,6 +807,33 @@ describe('validateTranslationReferences — the showcase contact surface (#5415)
646807
expect(findings).toEqual([]);
647808
}, 60_000);
648809

810+
it('accepts `_views.default` — the key this very surface ships, and the one it was told to ship (#6038)', () => {
811+
// The specimen behind #5164/#6038, on the real metadata rather than a
812+
// reduction: `ContactViews` declares a nameless default `list`, the CLI
813+
// i18n walker demands `objects.showcase_contact._views.default.label`
814+
// (#6124), and `examples/app-showcase` ships exactly that key. Before this
815+
// rule read the key from the composer it answered "no view of object
816+
// showcase_contact declares `default`" — one `os lint` run, two rules, no
817+
// author action that satisfied both. The control below keeps this honest:
818+
// `list`, the spelling the walker used to demand, is NOT legal.
819+
expect(
820+
validateTranslationReferences(
821+
showcaseContactStack([
822+
{ 'zh-CN': { objects: { showcase_contact: { _views: { default: { label: '联系人' } } } } } },
823+
]),
824+
),
825+
).toEqual([]);
826+
827+
const stale = validateTranslationReferences(
828+
showcaseContactStack([
829+
{ 'zh-CN': { objects: { showcase_contact: { _views: { list: { label: '联系人' } } } } } },
830+
]),
831+
);
832+
expect(stale).toHaveLength(1);
833+
expect(stale[0].path).toBe('translations[0]["zh-CN"].objects.showcase_contact._views.list');
834+
expect(stale[0].hint).toContain('default');
835+
}, 60_000);
836+
649837
it('still reports a section name nothing declares, and names the real ones', () => {
650838
// The over-widening control: `contract` is a typo of `contact`, and
651839
// `who_is_this` is the LABEL of `formViews.create`'s unnamed section — an

packages/lint/src/validate-translation-references.ts

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
* 4. unresolved, unprefixed → warn on the object key only
6666
*/
6767

68+
import { expandViewContainer } from '@objectstack/spec';
6869
import { hasPlatformObjectPrefix, isPlatformProvidedObjectName } from '@objectstack/spec/system';
6970
import { walkPageComponents } from './page-walk.js';
7071
import { SYSTEM_FIELDS } from './system-fields.js';
@@ -219,6 +220,13 @@ function emptyFacts(): ObjectFacts {
219220
* the canonical shape, which silently drops the whole record — a rule that
220221
* then reports every view key the app ships.
221222
*
223+
* The default `list` is the ONE place where "read the author's `name`" was
224+
* wrong, and #5164 is why: the runtime does not key that view by its `name`,
225+
* it keys it by the identity the composer assigns. Its key therefore comes
226+
* from {@link defaultListViewKey} — asked of the composer, never re-derived
227+
* here. See that function for the three facts that live in the composer and
228+
* nowhere else.
229+
*
222230
* A third thing was learned later, from the showcase (#5415): the container's
223231
* DEFAULT form (`form`) is a section anchor too. It is not one of the
224232
* `formViews.*` entries and it is not the record's own `sections` either, so
@@ -257,7 +265,7 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec
257265
};
258266

259267
const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined;
260-
if (isRec(view.list)) addView(listBinding, strName(view.list.name));
268+
if (isRec(view.list)) addView(listBinding, defaultListViewKey(listBinding, view));
261269
addView(recordObject ?? listBinding, strName(view.name));
262270

263271
for (const key of ['listViews', 'formViews'] as const) {
@@ -276,13 +284,65 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec
276284
// and `ObjectForm` renders when no named form view is asked for. Bound the
277285
// way the CLI i18n walker's `viewObjectName` resolves `view.form.data.object`
278286
// (#5415), so the two agree on which object the headings belong to.
279-
// Deliberately sections only: the default form has no map key, and whether it
280-
// contributes a `_views` name is the neighbouring question #5164 owns.
287+
//
288+
// Deliberately sections only, and #5164 is now settled enough to say WHY
289+
// rather than defer: the composer does give the default form a runtime
290+
// identity (`<object>.form`), but `_views.*` is a LIST-view convention —
291+
// `viewLabel` / `viewDescription` resolve view tabs, and the i18n walker
292+
// emits no `_views` entry for any form view (`i18n-extract.ts`: "form views
293+
// have no counterpart in the `viewLabel` / `_views.*` resolver convention").
294+
// A `_views` name registered here for the default form would make a key
295+
// legal that no consumer ever reads. Its SECTIONS are a different matter —
296+
// `ObjectForm` resolves those, which is exactly what #5415 established.
281297
if (isRec(view.form)) addSections(view.form, bindingOf(view.form) ?? listBinding);
282298

283299
addSections(view, recordObject ?? listBinding);
284300
}
285301

302+
/**
303+
* The bare `_views` key the RUNTIME assigns to a container's default `list` —
304+
* the only key a bundle can legally spell for that view.
305+
*
306+
* Asked of the composer (`expandViewContainer`, `spec/src/ui/view.zod.ts`)
307+
* rather than re-derived here. This rule used to read `view.list.name` and
308+
* register nothing when the author wrote none, while the composer named that
309+
* very same view `<object>.default` — so a container declaring only a default
310+
* `list` produced a registry entry keyed `default` and a lint fact set that
311+
* knew no such view. The CLI i18n walker demanded `_views.default.label`
312+
* (#6124, leg 1) and this rule called the key an orphan, in ONE `os lint` run:
313+
* six instances on the showcase, and no author action could make both green.
314+
* Ruled 2026-08-06 (#5164): canonical = the runtime identity's bare key.
315+
* Leg 1's `defaultListViewKey` in `packages/cli/src/utils/i18n-extract.ts` is
316+
* this function's twin — deliberately, both are thin readers of the composer
317+
* rather than a third and fourth derivation of the key.
318+
*
319+
* Three facts live in the composer and nowhere else, all load-bearing here:
320+
*
321+
* 1. a nameless default list is keyed **`default`** (never `list`), and a
322+
* named one keeps the author's `list.name`;
323+
* 2. a default list whose STRUCTURE merely restates a `listViews` entry is
324+
* **collapsed into that entry** and has no key of its own — the
325+
* `examples/app-crm` shape. The surviving `listViews` key is returned (the
326+
* `listViews` loop registers it anyway, so the set is unchanged), and the
327+
* collapsed-away `list.name` correctly stops being a legal key: nothing
328+
* resolves it;
329+
* 3. a key renamed by a collision (`default` → `default_2`, when another view
330+
* already claimed `default`) is returned as renamed, because the rename is
331+
* the registry key too.
332+
*
333+
* Returns `undefined` when the record declares no default `list`, or when no
334+
* object binding resolved — the caller cannot file a fact without one.
335+
*/
336+
function defaultListViewKey(object: string | undefined, container: AnyRec): string | undefined {
337+
if (!object || !isRec(container.list)) return undefined;
338+
const item = expandViewContainer(object, container).find(
339+
(i) => i.viewKind === 'list' && i.isDefault,
340+
);
341+
if (!item) return undefined;
342+
const prefix = `${object}.`;
343+
return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
344+
}
345+
286346
/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */
287347
function viewObjectName(view: AnyRec): string | undefined {
288348
return (

0 commit comments

Comments
 (0)