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
19 changes: 19 additions & 0 deletions .changeset/view-record-map-key-only.md
Original file line number Diff line number Diff line change
@@ -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`).
136 changes: 136 additions & 0 deletions packages/lint/src/validate-translation-references.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>` / `formViews.<key>`
// 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<string, unknown>) => ({
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: [
Expand Down
90 changes: 75 additions & 15 deletions packages/lint/src/validate-translation-references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>` and `formViews.<key>`, 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.<key>` and `formViews.<key>`.
* 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
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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.<key>` / `formViews.<key>` — 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 (`<object>.default` already claimed by
* the default `list` ⇒ `formViews.default` is registered as
* `<object>.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<string | undefined>;
form: Array<string | undefined>;
} {
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 (
Expand Down
Loading