From 3d2c0473fcbcb75344aa9d5c6b6b85d31ae79ec3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 17:28:21 +0000 Subject: [PATCH] fix(lint): key a container's default `list` `_views` name by the runtime identity (#5164) (#6038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `.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.._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 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- .changeset/lint-views-key-runtime-identity.md | 42 ++++ .../validate-translation-references.test.ts | 188 ++++++++++++++++++ .../src/validate-translation-references.ts | 66 +++++- 3 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 .changeset/lint-views-key-runtime-identity.md diff --git a/.changeset/lint-views-key-runtime-identity.md b/.changeset/lint-views-key-runtime-identity.md new file mode 100644 index 0000000000..2af6b12b12 --- /dev/null +++ b/.changeset/lint-views-key-runtime-identity.md @@ -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`)给同一个视图的身份是 +`.default`。第 1 棒(#6124)已把 i18n 提取器改为向组装器查询同一个键,于是 +**同一次 `os lint` 运行里**出现了一对自相矛盾的结论: + +- 要求方 `i18n/missing-view`:`objects.._views.default.label` 缺翻译; +- 否定方 `translation-target-unknown`:`_views.default` 是孤儿键,「no view of 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.` 同签名,包里写 `_views..*` | 通过(漏报) | 报孤儿 —— 该键运行时解析不到 | +| 默认 `list` 因冲突被改名 `default_2`,包里写 `_views.default_2.*` | 报孤儿(误报) | 通过 | + +本仓库 12 个受棘轮覆盖的配置上实测:**新增 0 条**,消除 8 条误报; +`check:i18n-coverage` 基线不变(该棘轮只数 `i18n/` 前缀,本规则不在其内)。 + +裁决依据:维护者 2026-08-06(#5164)—— `_views` 翻译键的 canonical 拼写 = 运行时身份的 +裸键。第 3 棒 objectui `viewSuffixes` 去第二候选(objectui#3502)不在本次变更内。 diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 409242a972..9926318e66 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -411,6 +411,13 @@ 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: [ @@ -418,6 +425,7 @@ describe('validateTranslationReferences — the canonical view-record shape', () list: { type: 'grid', name: 'all_leads', + label: 'All Leads', data: { provider: 'object', object: 'crm_lead' }, }, listViews: { @@ -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) => ({ + 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: [ @@ -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 diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index c831e120eb..6c74939e31 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -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'; @@ -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 @@ -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) { @@ -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 (`.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 `.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 (