From 0b1f5f87f8557a69c6f2b638cf66349c94d364c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 18:04:03 +0000 Subject: [PATCH] fix(lint): named listViews/formViews entries keyed by the runtime identity, single spelling (#6422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectViewRecord accepted two spellings for a named view entry — the map key and the entry's inner name — while the composer constructs the runtime identity from the map key alone. Per the #5164 ruling (canonical = the runtime identity's bare key), the named branches now read their keys from the composer via namedViewKeys, defaultListViewKey's sibling: a diverging inner name stops being legal, and a collision-renamed entry becomes legal under the renamed key — the one spelling the runtime resolves. Measured over the 12 ratchet-covered configs: os lint verdicts are byte-identical before/after (added: 0 / removed: 0). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn --- .changeset/view-record-map-key-only.md | 19 +++ .../validate-translation-references.test.ts | 136 ++++++++++++++++++ .../src/validate-translation-references.ts | 90 ++++++++++-- 3 files changed, 230 insertions(+), 15 deletions(-) create mode 100644 .changeset/view-record-map-key-only.md diff --git a/.changeset/view-record-map-key-only.md b/.changeset/view-record-map-key-only.md new file mode 100644 index 0000000000..2c21465084 --- /dev/null +++ b/.changeset/view-record-map-key-only.md @@ -0,0 +1,19 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `_views` keys for named `listViews`/`formViews` entries are the runtime's, single spelling (#6422) + +`validateTranslationReferences` accepted two spellings for a named view entry — +the map key and the entry's inner `name` — while the composer +(`expandViewContainerWithDiagnostics`) constructs the runtime identity from the +map key alone and ignores `name` entirely. Per the #5164 ruling (canonical = +the runtime identity's bare key), the named branches now read their keys from +the composer, exactly as the default `list` already does: an inner `name` +diverging from its map key stops being a legal bundle key (the runtime never +resolves it), and a collision-renamed entry (`formViews.default` beside a +default `list` → `default_2`) becomes legal under the renamed key — the one +spelling that actually resolves — instead of being reported as an orphan. + +Measured over all 12 ratchet-covered configs in this repo: `os lint` verdicts +are byte-identical before/after (`added: 0 / removed: 0`). diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 9926318e66..9dfea3d23f 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -633,6 +633,142 @@ describe('validateTranslationReferences — the canonical view-record shape', () }); }); + // ── #6422 / #5164 leg 3: the NAMED entries' keys are the RUNTIME's too ──── + // + // The composer constructs every `listViews.` / `formViews.` + // identity from the MAP KEY alone — the inner `name` is ignored — and + // renames on collision. This rule therefore reads the named entries' keys + // from the composer (`namedViewKeys`), exactly as it reads the default + // list's (`defaultListViewKey`). Same discipline as the #6038 block above: + // every "legal" assertion is paired with a planted bad key on the SAME + // fixture, so a green run is never an empty run. + describe('named entries are keyed by the runtime identity, single spelling', () => { + const bundle = (views: Record) => ({ + translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: views } } } }], + }); + + it('an inner `name` diverging from its map key is not a legal `_views` key — the runtime never resolves it', () => { + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + listViews: { + my_leads: { + name: 'open_leads', + type: 'grid', + data: { provider: 'object', object: 'crm_lead' }, + }, + }, + }, + ], + }; + // The map key is the registry key… + expect( + validateTranslationReferences({ ...stack, ...bundle({ my_leads: { label: 'My Leads' } }) }), + ).toEqual([]); + // …and the inner `name` is a key nothing resolves. This spelling used to + // be accepted ("authors write either", HotCRM); #5164's ruling — canonical + // = the runtime identity's bare key — retires it on the named branches. + const findings = validateTranslationReferences({ + ...stack, + ...bundle({ open_leads: { label: 'Open Leads' } }), + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.open_leads'); + }); + + it('the same narrowing holds on the formViews branch', () => { + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + formViews: { + quick: { + name: 'quick_form', + type: 'simple', + data: { provider: 'object', object: 'crm_lead' }, + }, + }, + }, + ], + }; + expect( + validateTranslationReferences({ ...stack, ...bundle({ quick: { label: 'Quick' } }) }), + ).toEqual([]); + const findings = validateTranslationReferences({ + ...stack, + ...bundle({ quick_form: { label: 'Quick Form' } }), + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.quick_form'); + }); + + it('a collision-renamed formViews entry is legal under the renamed key — the author who wrote the registry key is not an orphan', () => { + // The #6422 sharp case. The nameless default `list` claims + // `crm_lead.default` first, so `formViews.default` is renamed + // `crm_lead.default_2` — and the rename IS the registry key. Before this + // rule asked the composer, it accepted `default` for the form (a key + // that resolves to the LIST) and reported `default_2` — the one spelling + // that actually resolves the form — as an orphan. + // + // The shape is dormant in shipped configs only because the view-ref lint + // (`lint-view-refs.ts`) makes every view-key collision a hard error. + // That dormancy depends on ANOTHER rule staying strict, which is exactly + // why it is pinned here instead of trusted silently. + const collided = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } }, + formViews: { + default: { type: 'simple', data: { provider: 'object', object: 'crm_lead' } }, + }, + }, + ], + }; + // `default` resolves the list, `default_2` resolves the form: both are + // registry keys, so both are legal bundle spellings. + expect( + validateTranslationReferences({ + ...collided, + ...bundle({ default: { label: 'All' }, default_2: { label: 'Form' } }), + }), + ).toEqual([]); + // Planted bad key on the SAME fixture: the green above is not an empty run. + const findings = validateTranslationReferences({ + ...collided, + ...bundle({ default_3: { label: 'Ghost' } }), + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.default_3'); + }); + + it('an inner `name` that MATCHES its map key stays legal — the narrowing removes a spelling, not a view', () => { + // The overwhelmingly common authored shape (every in-repo config): the + // author restates the map key as `name`. One key, one spelling — the + // map key — and it still resolves. + const stack = { + objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }], + views: [ + { + listViews: { + recent: { name: 'recent', type: 'grid', data: { provider: 'object', object: 'crm_lead' } }, + }, + }, + ], + }; + expect( + validateTranslationReferences({ ...stack, ...bundle({ recent: { label: 'Recent' } }) }), + ).toEqual([]); + const findings = validateTranslationReferences({ + ...stack, + ...bundle({ recent: { label: 'Recent' }, stale: { label: 'Stale' } }), + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.stale'); + }); + }); + it('resolves views embedded on the object itself', () => { const findings = validateTranslationReferences({ objects: [ diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 6c74939e31..0fcee13b5d 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -211,21 +211,26 @@ function emptyFacts(): ObjectFacts { * where a first pass reported ~40 correct keys as orphans: * * 1. A view record is a CONTAINER, not a view. The default list sits at - * `list`; the named tabs at `listViews.` and `formViews.`, each - * of which may also carry its own `name`. Both the map key and the inner - * `name` are accepted — authors write either, and the key is what the - * console renders the tab from. + * `list`; the named tabs at `listViews.` and `formViews.`. * 2. The object binding lives INSIDE the container (`list.data.object`), not * at the record root. A record-level lookup alone resolves to nothing on * 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. + * NOWHERE here is the author's inner `name` a `_views` key. This rule used to + * read it on the named branches too ("authors write either", from the HotCRM + * corpus) — but the composer constructs every named entry's runtime identity + * from the MAP KEY alone and ignores `name` entirely, so an inner `name` that + * diverges from its map key is a key the runtime never resolves, and #5164 + * (ruled 2026-08-06: canonical = the runtime identity's bare key) applies to + * the named branches exactly as it applies to the default `list` (#6422). + * Every `_views` key therefore comes from the composer: the default list's + * via {@link defaultListViewKey}, the named entries' via {@link namedViewKeys} + * — asked, never re-derived. See those functions for the composer facts that + * live there and nowhere else (collision renames included: `formViews.default` + * beside a default `list` is registered as `default_2`, because the rename IS + * the registry key — the map key it was written under resolves to the OTHER + * view). * * 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 @@ -268,14 +273,20 @@ function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => Objec if (isRec(view.list)) addView(listBinding, defaultListViewKey(listBinding, view)); addView(recordObject ?? listBinding, strName(view.name)); - for (const key of ['listViews', 'formViews'] as const) { - const container = view[key]; + const named = namedViewKeys(view); + for (const family of ['listViews', 'formViews'] as const) { + const container = view[family]; if (!isRec(container)) continue; - for (const [subKey, sub] of Object.entries(container)) { + const registryKeys = family === 'listViews' ? named.list : named.form; + let at = 0; + for (const sub of Object.values(container)) { + // Advance in lockstep with the composer: it makes an item for every + // object-typed value (arrays included), so the index moves for each. + if (!sub || typeof sub !== 'object') continue; + const registryKey = registryKeys[at++]; if (!isRec(sub)) continue; const binding = bindingOf(sub) ?? listBinding; - addView(binding, subKey); - addView(binding, strName(sub.name)); + addView(binding, registryKey); addSections(sub, binding); } } @@ -343,6 +354,55 @@ function defaultListViewKey(object: string | undefined, container: AnyRec): stri return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name; } +/** + * The bare `_views` keys the RUNTIME assigns to a container's NAMED entries — + * `listViews.` / `formViews.` — in authoring order per family. + * + * {@link defaultListViewKey}'s sibling, and a thin reader of the same composer + * (#6422, the named-branch limb of the #5164 ruling): the composer constructs + * each named entry's identity from the MAP KEY alone — the inner `name` is + * ignored — and renames on collision (`.default` already claimed by + * the default `list` ⇒ `formViews.default` is registered as + * `.default_2`). Both facts live in the composer and nowhere else, so + * both are asked of it, never re-derived. The rename matters even though the + * view-ref lint makes collisions a hard error: this rule must agree with the + * registry, not with another rule staying strict — under a collision the map + * key spelled by the author resolves to the OTHER view, and the renamed key is + * the one the bundle must spell. + * + * Alignment with the composer is positional and rests on two documented facts + * of `expandViewContainerWithDiagnostics`: each family expands its named map's + * entries FIRST (defaults are appended after), and it makes an item for every + * object-typed value in the map, in `Object.entries` order. So the first N + * items of a family are the named entries, index-aligned with the map. + * + * The object passed to the composer is a fixed probe: every identity it + * assigns is `${object}.${key}` with the SAME object, so the bare key — + * renames included — does not depend on it, and a container whose entries bind + * different objects still gets each key filed under its own entry's binding by + * the caller. + */ +function namedViewKeys(container: AnyRec): { + list: Array; + form: Array; +} { + const object = 'probe'; + const prefix = `${object}.`; + const bare = (name: string) => (name.startsWith(prefix) ? name.slice(prefix.length) : name); + const countEntries = (v: unknown) => + isRec(v) ? Object.values(v).filter((e) => !!e && typeof e === 'object').length : 0; + const listCount = countEntries(container.listViews); + const formCount = countEntries(container.formViews); + if (!listCount && !formCount) return { list: [], form: [] }; + const items = expandViewContainer(object, container); + const keysOf = (kind: 'list' | 'form', count: number) => + items + .filter((i) => i.viewKind === kind) + .slice(0, count) + .map((i) => bare(i.name)); + return { list: keysOf('list', listCount), form: keysOf('form', formCount) }; +} + /** 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 (