Skip to content

Commit 1bb679c

Browse files
fix(lint): named listViews/formViews entries keyed by the runtime identity, single spelling (#6422) (#6800)
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). Claude-Session: https://claude.ai/code/session_01F8q5J1MQyocgtNspb15fSn Co-authored-by: Claude <noreply@anthropic.com>
1 parent fec7848 commit 1bb679c

3 files changed

Lines changed: 230 additions & 15 deletions

File tree

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `_views` keys for named `listViews`/`formViews` entries are the runtime's, single spelling (#6422)
6+
7+
`validateTranslationReferences` accepted two spellings for a named view entry —
8+
the map key and the entry's inner `name` — while the composer
9+
(`expandViewContainerWithDiagnostics`) constructs the runtime identity from the
10+
map key alone and ignores `name` entirely. Per the #5164 ruling (canonical =
11+
the runtime identity's bare key), the named branches now read their keys from
12+
the composer, exactly as the default `list` already does: an inner `name`
13+
diverging from its map key stops being a legal bundle key (the runtime never
14+
resolves it), and a collision-renamed entry (`formViews.default` beside a
15+
default `list``default_2`) becomes legal under the renamed key — the one
16+
spelling that actually resolves — instead of being reported as an orphan.
17+
18+
Measured over all 12 ratchet-covered configs in this repo: `os lint` verdicts
19+
are byte-identical before/after (`added: 0 / removed: 0`).

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,142 @@ describe('validateTranslationReferences — the canonical view-record shape', ()
633633
});
634634
});
635635

636+
// ── #6422 / #5164 leg 3: the NAMED entries' keys are the RUNTIME's too ────
637+
//
638+
// The composer constructs every `listViews.<key>` / `formViews.<key>`
639+
// identity from the MAP KEY alone — the inner `name` is ignored — and
640+
// renames on collision. This rule therefore reads the named entries' keys
641+
// from the composer (`namedViewKeys`), exactly as it reads the default
642+
// list's (`defaultListViewKey`). Same discipline as the #6038 block above:
643+
// every "legal" assertion is paired with a planted bad key on the SAME
644+
// fixture, so a green run is never an empty run.
645+
describe('named entries are keyed by the runtime identity, single spelling', () => {
646+
const bundle = (views: Record<string, unknown>) => ({
647+
translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: views } } } }],
648+
});
649+
650+
it('an inner `name` diverging from its map key is not a legal `_views` key — the runtime never resolves it', () => {
651+
const stack = {
652+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
653+
views: [
654+
{
655+
listViews: {
656+
my_leads: {
657+
name: 'open_leads',
658+
type: 'grid',
659+
data: { provider: 'object', object: 'crm_lead' },
660+
},
661+
},
662+
},
663+
],
664+
};
665+
// The map key is the registry key…
666+
expect(
667+
validateTranslationReferences({ ...stack, ...bundle({ my_leads: { label: 'My Leads' } }) }),
668+
).toEqual([]);
669+
// …and the inner `name` is a key nothing resolves. This spelling used to
670+
// be accepted ("authors write either", HotCRM); #5164's ruling — canonical
671+
// = the runtime identity's bare key — retires it on the named branches.
672+
const findings = validateTranslationReferences({
673+
...stack,
674+
...bundle({ open_leads: { label: 'Open Leads' } }),
675+
});
676+
expect(findings).toHaveLength(1);
677+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.open_leads');
678+
});
679+
680+
it('the same narrowing holds on the formViews branch', () => {
681+
const stack = {
682+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
683+
views: [
684+
{
685+
formViews: {
686+
quick: {
687+
name: 'quick_form',
688+
type: 'simple',
689+
data: { provider: 'object', object: 'crm_lead' },
690+
},
691+
},
692+
},
693+
],
694+
};
695+
expect(
696+
validateTranslationReferences({ ...stack, ...bundle({ quick: { label: 'Quick' } }) }),
697+
).toEqual([]);
698+
const findings = validateTranslationReferences({
699+
...stack,
700+
...bundle({ quick_form: { label: 'Quick Form' } }),
701+
});
702+
expect(findings).toHaveLength(1);
703+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.quick_form');
704+
});
705+
706+
it('a collision-renamed formViews entry is legal under the renamed key — the author who wrote the registry key is not an orphan', () => {
707+
// The #6422 sharp case. The nameless default `list` claims
708+
// `crm_lead.default` first, so `formViews.default` is renamed
709+
// `crm_lead.default_2` — and the rename IS the registry key. Before this
710+
// rule asked the composer, it accepted `default` for the form (a key
711+
// that resolves to the LIST) and reported `default_2` — the one spelling
712+
// that actually resolves the form — as an orphan.
713+
//
714+
// The shape is dormant in shipped configs only because the view-ref lint
715+
// (`lint-view-refs.ts`) makes every view-key collision a hard error.
716+
// That dormancy depends on ANOTHER rule staying strict, which is exactly
717+
// why it is pinned here instead of trusted silently.
718+
const collided = {
719+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
720+
views: [
721+
{
722+
list: { type: 'grid', data: { provider: 'object', object: 'crm_lead' } },
723+
formViews: {
724+
default: { type: 'simple', data: { provider: 'object', object: 'crm_lead' } },
725+
},
726+
},
727+
],
728+
};
729+
// `default` resolves the list, `default_2` resolves the form: both are
730+
// registry keys, so both are legal bundle spellings.
731+
expect(
732+
validateTranslationReferences({
733+
...collided,
734+
...bundle({ default: { label: 'All' }, default_2: { label: 'Form' } }),
735+
}),
736+
).toEqual([]);
737+
// Planted bad key on the SAME fixture: the green above is not an empty run.
738+
const findings = validateTranslationReferences({
739+
...collided,
740+
...bundle({ default_3: { label: 'Ghost' } }),
741+
});
742+
expect(findings).toHaveLength(1);
743+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.default_3');
744+
});
745+
746+
it('an inner `name` that MATCHES its map key stays legal — the narrowing removes a spelling, not a view', () => {
747+
// The overwhelmingly common authored shape (every in-repo config): the
748+
// author restates the map key as `name`. One key, one spelling — the
749+
// map key — and it still resolves.
750+
const stack = {
751+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
752+
views: [
753+
{
754+
listViews: {
755+
recent: { name: 'recent', type: 'grid', data: { provider: 'object', object: 'crm_lead' } },
756+
},
757+
},
758+
],
759+
};
760+
expect(
761+
validateTranslationReferences({ ...stack, ...bundle({ recent: { label: 'Recent' } }) }),
762+
).toEqual([]);
763+
const findings = validateTranslationReferences({
764+
...stack,
765+
...bundle({ recent: { label: 'Recent' }, stale: { label: 'Stale' } }),
766+
});
767+
expect(findings).toHaveLength(1);
768+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.stale');
769+
});
770+
});
771+
636772
it('resolves views embedded on the object itself', () => {
637773
const findings = validateTranslationReferences({
638774
objects: [

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

Lines changed: 75 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -211,21 +211,26 @@ function emptyFacts(): ObjectFacts {
211211
* where a first pass reported ~40 correct keys as orphans:
212212
*
213213
* 1. A view record is a CONTAINER, not a view. The default list sits at
214-
* `list`; the named tabs at `listViews.<key>` and `formViews.<key>`, each
215-
* of which may also carry its own `name`. Both the map key and the inner
216-
* `name` are accepted — authors write either, and the key is what the
217-
* console renders the tab from.
214+
* `list`; the named tabs at `listViews.<key>` and `formViews.<key>`.
218215
* 2. The object binding lives INSIDE the container (`list.data.object`), not
219216
* at the record root. A record-level lookup alone resolves to nothing on
220217
* the canonical shape, which silently drops the whole record — a rule that
221218
* then reports every view key the app ships.
222219
*
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.
220+
* NOWHERE here is the author's inner `name` a `_views` key. This rule used to
221+
* read it on the named branches too ("authors write either", from the HotCRM
222+
* corpus) — but the composer constructs every named entry's runtime identity
223+
* from the MAP KEY alone and ignores `name` entirely, so an inner `name` that
224+
* diverges from its map key is a key the runtime never resolves, and #5164
225+
* (ruled 2026-08-06: canonical = the runtime identity's bare key) applies to
226+
* the named branches exactly as it applies to the default `list` (#6422).
227+
* Every `_views` key therefore comes from the composer: the default list's
228+
* via {@link defaultListViewKey}, the named entries' via {@link namedViewKeys}
229+
* — asked, never re-derived. See those functions for the composer facts that
230+
* live there and nowhere else (collision renames included: `formViews.default`
231+
* beside a default `list` is registered as `default_2`, because the rename IS
232+
* the registry key — the map key it was written under resolves to the OTHER
233+
* view).
229234
*
230235
* A third thing was learned later, from the showcase (#5415): the container's
231236
* 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
268273
if (isRec(view.list)) addView(listBinding, defaultListViewKey(listBinding, view));
269274
addView(recordObject ?? listBinding, strName(view.name));
270275

271-
for (const key of ['listViews', 'formViews'] as const) {
272-
const container = view[key];
276+
const named = namedViewKeys(view);
277+
for (const family of ['listViews', 'formViews'] as const) {
278+
const container = view[family];
273279
if (!isRec(container)) continue;
274-
for (const [subKey, sub] of Object.entries(container)) {
280+
const registryKeys = family === 'listViews' ? named.list : named.form;
281+
let at = 0;
282+
for (const sub of Object.values(container)) {
283+
// Advance in lockstep with the composer: it makes an item for every
284+
// object-typed value (arrays included), so the index moves for each.
285+
if (!sub || typeof sub !== 'object') continue;
286+
const registryKey = registryKeys[at++];
275287
if (!isRec(sub)) continue;
276288
const binding = bindingOf(sub) ?? listBinding;
277-
addView(binding, subKey);
278-
addView(binding, strName(sub.name));
289+
addView(binding, registryKey);
279290
addSections(sub, binding);
280291
}
281292
}
@@ -343,6 +354,55 @@ function defaultListViewKey(object: string | undefined, container: AnyRec): stri
343354
return item.name.startsWith(prefix) ? item.name.slice(prefix.length) : item.name;
344355
}
345356

357+
/**
358+
* The bare `_views` keys the RUNTIME assigns to a container's NAMED entries —
359+
* `listViews.<key>` / `formViews.<key>` — in authoring order per family.
360+
*
361+
* {@link defaultListViewKey}'s sibling, and a thin reader of the same composer
362+
* (#6422, the named-branch limb of the #5164 ruling): the composer constructs
363+
* each named entry's identity from the MAP KEY alone — the inner `name` is
364+
* ignored — and renames on collision (`<object>.default` already claimed by
365+
* the default `list` ⇒ `formViews.default` is registered as
366+
* `<object>.default_2`). Both facts live in the composer and nowhere else, so
367+
* both are asked of it, never re-derived. The rename matters even though the
368+
* view-ref lint makes collisions a hard error: this rule must agree with the
369+
* registry, not with another rule staying strict — under a collision the map
370+
* key spelled by the author resolves to the OTHER view, and the renamed key is
371+
* the one the bundle must spell.
372+
*
373+
* Alignment with the composer is positional and rests on two documented facts
374+
* of `expandViewContainerWithDiagnostics`: each family expands its named map's
375+
* entries FIRST (defaults are appended after), and it makes an item for every
376+
* object-typed value in the map, in `Object.entries` order. So the first N
377+
* items of a family are the named entries, index-aligned with the map.
378+
*
379+
* The object passed to the composer is a fixed probe: every identity it
380+
* assigns is `${object}.${key}` with the SAME object, so the bare key —
381+
* renames included — does not depend on it, and a container whose entries bind
382+
* different objects still gets each key filed under its own entry's binding by
383+
* the caller.
384+
*/
385+
function namedViewKeys(container: AnyRec): {
386+
list: Array<string | undefined>;
387+
form: Array<string | undefined>;
388+
} {
389+
const object = 'probe';
390+
const prefix = `${object}.`;
391+
const bare = (name: string) => (name.startsWith(prefix) ? name.slice(prefix.length) : name);
392+
const countEntries = (v: unknown) =>
393+
isRec(v) ? Object.values(v).filter((e) => !!e && typeof e === 'object').length : 0;
394+
const listCount = countEntries(container.listViews);
395+
const formCount = countEntries(container.formViews);
396+
if (!listCount && !formCount) return { list: [], form: [] };
397+
const items = expandViewContainer(object, container);
398+
const keysOf = (kind: 'list' | 'form', count: number) =>
399+
items
400+
.filter((i) => i.viewKind === kind)
401+
.slice(0, count)
402+
.map((i) => bare(i.name));
403+
return { list: keysOf('list', listCount), form: keysOf('form', formCount) };
404+
}
405+
346406
/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */
347407
function viewObjectName(view: AnyRec): string | undefined {
348408
return (

0 commit comments

Comments
 (0)