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
42 changes: 42 additions & 0 deletions .changeset/lint-views-key-runtime-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@objectstack/lint': patch
---

`translation-target-unknown` 按运行时视图身份判定容器默认 `list` 的 `_views` 键(#5164 第 2 棒 / lint 段)

`validate-translation-references` 的 `collectViewRecord()` 过去读 `view.list.name`
来决定容器默认列表贡献哪个 `_views` 名 —— 作者没写 `name` 时它什么也不注册,而组装器
(`expandViewContainer`,`packages/spec/src/ui/view.zod.ts`)给同一个视图的身份是
`<object>.default`。第 1 棒(#6124)已把 i18n 提取器改为向组装器查询同一个键,于是
**同一次 `os lint` 运行里**出现了一对自相矛盾的结论:

- 要求方 `i18n/missing-view`:`objects.<object>._views.default.label` 缺翻译;
- 否定方 `translation-target-unknown`:`_views.default` 是孤儿键,「no view of object
`<object>` declares it」。

作者补了译文被判孤儿,删了译文被判缺翻译,两条都躲不掉。本仓库自带示例上实测有 8 处
(`examples/app-showcase` 6 处、`examples/app-todo` 2 处)。

本规则现在同样**向组装器查询**这个键,而不是第三次自行推导,因此继承了组装器仅有的三条
规则:

- 无 `name` 的默认列表键为 `default`;带 `name` 的沿用作者的 `name`;
- 结构上与某个 `listViews` 条目完全相同的默认列表被组装器按签名**折叠**进该条目,只有
存活的那个键合法 —— 被折叠掉的 `list.name` 不再是合法键(`examples/app-crm` 形状);
- 因命名冲突被改名的键(`default` → `default_2`)按**改名后**判定,因为改名后的名字才是
注册表键。

## 判定变化(全是 warning,不改 `os lint` 退出码)

| 形状 | 变化前 | 变化后 |
|---|---|---|
| 默认 `list` 无 `name`,包里写 `_views.default.*` | 报孤儿(误报) | 通过 |
| 默认 `list` 无 `name`,包里写 `_views.list.*` | 报孤儿 | 报孤儿(不变;提示语现在会列出 `default`) |
| 默认 `list` 与某个 `listViews.<k>` 同签名,包里写 `_views.<list.name>.*` | 通过(漏报) | 报孤儿 —— 该键运行时解析不到 |
| 默认 `list` 因冲突被改名 `default_2`,包里写 `_views.default_2.*` | 报孤儿(误报) | 通过 |

本仓库 12 个受棘轮覆盖的配置上实测:**新增 0 条**,消除 8 条误报;
`check:i18n-coverage` 基线不变(该棘轮只数 `i18n/` 前缀,本规则不在其内)。

裁决依据:维护者 2026-08-06(#5164)—— `_views` 翻译键的 canonical 拼写 = 运行时身份的
裸键。第 3 棒 objectui `viewSuffixes` 去第二候选(objectui#3502)不在本次变更内。
188 changes: 188 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -411,13 +411,21 @@ describe('validateTranslationReferences — the canonical view-record shape', ()
// Reading `view.name` / `view.data.object` at the record root resolves
// nothing here, drops the record, and reports every view key the app ships —
// ~40 correct keys on the real corpus.
//
// The default list carries a `label` (#6038): without one it is
// signature-identical to `listViews.my_leads` (`{type,label,columns}` all
// equal), the composer collapses the two, and `all_leads` is not a runtime
// view name at all — so the fixture would be asserting that a key nothing
// resolves is legal. The label makes it the distinct default list this test
// says it is. The collapse itself is pinned separately below.
const leadViews = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: {
type: 'grid',
name: 'all_leads',
label: 'All Leads',
data: { provider: 'object', object: 'crm_lead' },
},
listViews: {
Expand Down Expand Up @@ -472,6 +480,159 @@ describe('validateTranslationReferences — the canonical view-record shape', ()
expect(findings[0].hint).toContain('all_leads');
});

// ── #6038 / #5164 leg 2: the default list's key is the RUNTIME's ─────────
//
// The composer (`expandViewContainer`) is the single producer of a view's
// runtime identity, and these pin that this rule reads the key from it
// instead of re-deriving one. Every fixture below is driven through the real
// `validateTranslationReferences`, and every "legal" assertion is paired with
// a planted bad key on the SAME fixture — a `toEqual([])` that passes because
// the rule produced nothing at all would prove nothing.
describe('the default list is keyed by the runtime identity, single spelling', () => {
/** The showcase shape: a container declaring ONLY a nameless default list. */
const namelessDefaultList = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: { type: 'grid', label: 'All Leads', data: { provider: 'object', object: 'crm_lead' } },
},
],
};

const bundle = (views: Record<string, unknown>) => ({
translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: views } } } }],
});

it('accepts `default` for a nameless default list — the key the registry holds', () => {
const findings = validateTranslationReferences({
...namelessDefaultList,
...bundle({ default: { label: '全部线索' } }),
});
expect(findings).toEqual([]);
});

it('the same fixture still reports a key nothing declares (the green above is not an empty run)', () => {
const findings = validateTranslationReferences({
...namelessDefaultList,
...bundle({ default: { label: '全部线索' }, hot_leads: { label: 'Hot' } }),
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.hot_leads');
});

it('rejects the old `list` spelling — one key per view, and it is the runtime one', () => {
const findings = validateTranslationReferences({
...namelessDefaultList,
...bundle({ list: { label: '全部线索' } }),
});
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.list');
expect(findings[0].hint).toContain('default');
});

it('a named default list keeps the author\'s `name`', () => {
const stack = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: {
type: 'grid',
name: 'all_leads',
label: 'All Leads',
data: { provider: 'object', object: 'crm_lead' },
},
listViews: { my_leads: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } } },
},
],
};
expect(
validateTranslationReferences({ ...stack, ...bundle({ all_leads: { label: 'A' }, my_leads: { label: 'M' } }) }),
).toEqual([]);
// …and `default` is NOT legal here: the author named the view, so the
// composer never falls back to `default`.
const planted = validateTranslationReferences({ ...stack, ...bundle({ default: { label: 'D' } }) });
expect(planted).toHaveLength(1);
expect(planted[0].path).toBe('translations[0].en.objects.crm_lead._views.default');
});

it('a default list collapsed into a `listViews` entry contributes that entry\'s key, not its own `name`', () => {
// Composer fact 2 — the `examples/app-crm` shape: `list` is
// signature-identical to `listViews.all` (`{type,label,columns}` equal),
// so the two are ONE registry entry named `all`. `list.name` resolves to
// nothing and must not be a legal bundle key.
const collapsed = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: { type: 'grid', name: 'all_leads', data: { provider: 'object', object: 'crm_lead' } },
listViews: { all: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } } },
},
],
};
expect(validateTranslationReferences({ ...collapsed, ...bundle({ all: { label: '全部' } }) })).toEqual([]);
const findings = validateTranslationReferences({ ...collapsed, ...bundle({ all_leads: { label: '全部' } }) });
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.all_leads');
});

it('a collision-renamed default list is legal under the renamed key', () => {
// Composer fact 3: `listViews.default` claims `crm_lead.default` first,
// so the nameless default list is renamed `crm_lead.default_2` — and the
// rename IS the registry key, so it is what a bundle must spell.
const collided = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } },
listViews: { default: { type: 'kanban', data: { provider: 'object', object: 'crm_lead' } } },
},
],
};
expect(
validateTranslationReferences({ ...collided, ...bundle({ default: { label: 'D' }, default_2: { label: 'D2' } }) }),
).toEqual([]);
const findings = validateTranslationReferences({ ...collided, ...bundle({ default_3: { label: 'D3' } }) });
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.default_3');
});

it('the default FORM contributes sections but no `_views` name — `_views.*` is a list convention', () => {
// The composer does name the default form `crm_lead.form`, but the i18n
// walker emits no `_views` entry for any form view, so a `_views.form`
// key would be one nothing reads. Its `_sections` still resolve (#5415).
const withForm = {
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
views: [
{
list: { type: 'grid', label: 'All', data: { provider: 'object', object: 'crm_lead' } },
form: {
type: 'simple',
data: { provider: 'object', object: 'crm_lead' },
sections: [{ name: 'contact_info', label: 'Contact Info' }],
},
},
],
};
expect(
validateTranslationReferences({
...withForm,
translations: [
{
en: {
objects: {
crm_lead: { label: 'Lead', _views: { default: { label: 'All' } }, _sections: { contact_info: { label: '联系方式' } } },
},
},
},
],
}),
).toEqual([]);
const findings = validateTranslationReferences({ ...withForm, ...bundle({ form: { label: 'Form' } }) });
expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.form');
});
});

it('resolves views embedded on the object itself', () => {
const findings = validateTranslationReferences({
objects: [
Expand Down Expand Up @@ -646,6 +807,33 @@ describe('validateTranslationReferences — the showcase contact surface (#5415)
expect(findings).toEqual([]);
}, 60_000);

it('accepts `_views.default` — the key this very surface ships, and the one it was told to ship (#6038)', () => {
// The specimen behind #5164/#6038, on the real metadata rather than a
// reduction: `ContactViews` declares a nameless default `list`, the CLI
// i18n walker demands `objects.showcase_contact._views.default.label`
// (#6124), and `examples/app-showcase` ships exactly that key. Before this
// rule read the key from the composer it answered "no view of object
// showcase_contact declares `default`" — one `os lint` run, two rules, no
// author action that satisfied both. The control below keeps this honest:
// `list`, the spelling the walker used to demand, is NOT legal.
expect(
validateTranslationReferences(
showcaseContactStack([
{ 'zh-CN': { objects: { showcase_contact: { _views: { default: { label: '联系人' } } } } } },
]),
),
).toEqual([]);

const stale = validateTranslationReferences(
showcaseContactStack([
{ 'zh-CN': { objects: { showcase_contact: { _views: { list: { label: '联系人' } } } } } },
]),
);
expect(stale).toHaveLength(1);
expect(stale[0].path).toBe('translations[0]["zh-CN"].objects.showcase_contact._views.list');
expect(stale[0].hint).toContain('default');
}, 60_000);

it('still reports a section name nothing declares, and names the real ones', () => {
// The over-widening control: `contract` is a typo of `contact`, and
// `who_is_this` is the LABEL of `formViews.create`'s unnamed section — an
Expand Down
66 changes: 63 additions & 3 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
* 4. unresolved, unprefixed → warn on the object key only
*/

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

const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined;
if (isRec(view.list)) addView(listBinding, strName(view.list.name));
if (isRec(view.list)) addView(listBinding, defaultListViewKey(listBinding, view));
addView(recordObject ?? listBinding, strName(view.name));

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

addSections(view, recordObject ?? listBinding);
}

/**
* The bare `_views` key the RUNTIME assigns to a container's default `list` —
* the only key a bundle can legally spell for that view.
*
* Asked of the composer (`expandViewContainer`, `spec/src/ui/view.zod.ts`)
* rather than re-derived here. This rule used to read `view.list.name` and
* register nothing when the author wrote none, while the composer named that
* very same view `<object>.default` — so a container declaring only a default
* `list` produced a registry entry keyed `default` and a lint fact set that
* knew no such view. The CLI i18n walker demanded `_views.default.label`
* (#6124, leg 1) and this rule called the key an orphan, in ONE `os lint` run:
* six instances on the showcase, and no author action could make both green.
* Ruled 2026-08-06 (#5164): canonical = the runtime identity's bare key.
* Leg 1's `defaultListViewKey` in `packages/cli/src/utils/i18n-extract.ts` is
* this function's twin — deliberately, both are thin readers of the composer
* rather than a third and fourth derivation of the key.
*
* Three facts live in the composer and nowhere else, all load-bearing here:
*
* 1. a nameless default list is keyed **`default`** (never `list`), and a
* named one keeps the author's `list.name`;
* 2. a default list whose STRUCTURE merely restates a `listViews` entry is
* **collapsed into that entry** and has no key of its own — the
* `examples/app-crm` shape. The surviving `listViews` key is returned (the
* `listViews` loop registers it anyway, so the set is unchanged), and the
* collapsed-away `list.name` correctly stops being a legal key: nothing
* resolves it;
* 3. a key renamed by a collision (`default` → `default_2`, when another view
* already claimed `default`) is returned as renamed, because the rename is
* the registry key too.
*
* Returns `undefined` when the record declares no default `list`, or when no
* object binding resolved — the caller cannot file a fact without one.
*/
function defaultListViewKey(object: string | undefined, container: AnyRec): string | undefined {
if (!object || !isRec(container.list)) return undefined;
const item = expandViewContainer(object, container).find(
(i) => i.viewKind === 'list' && i.isDefault,
);
if (!item) return undefined;
const prefix = `${object}.`;
return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
}

/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */
function viewObjectName(view: AnyRec): string | undefined {
return (
Expand Down
Loading