Skip to content

Commit 1d12b62

Browse files
committed
fix(lint): a view record is a container — resolve _views keys against it (#3583)
Running the new rule over HotCRM (~18k lines of shipped metadata) reported ~40 `_views` keys as orphans. Every one of them was correct; the rule was wrong, in two compounding ways: 1. A view record is a CONTAINER, not a view. The default list lives at `list` and the named tabs at `listViews.<key>` / `formViews.<key>`, each of which may also carry its own `name`. Collecting only the record's own `name` makes every named tab look undeclared. 2. The object binding lives INSIDE the container (`list.data.object`), not at the record root. The record-level lookup resolved to nothing on the canonical shape, so the whole record was skipped — which is why the universe was empty enough to report the app's entire view surface. The examples missed this because none of their bundles carried `_views` keys at all: the walk was exercised, but that one branch of the universe never was. A corpus with the keys present is what caught it — §6.1's point about a false-positive floor only meaning something when the walk is non-empty applies per branch, not per rule. `collectViewRecord` now registers each container under the object THAT container binds (falling back to the record, then to the default list), and accepts both the map key and the inner `name` — authors write either, and the key is what the console renders the tab from. Form sections are collected in the same pass, so `_sections` gets the same binding fix. Objects carrying views directly (`objects[].views`, `objects[].listViews`) go through one code path with the owner pinned. Three regression tests pin the HotCRM shape. Residual on HotCRM after the fix: 6 findings, all true — `apps.crm_enterprise.navigation.group_products` and `.group_analytics` across three locales, where the app declares group_sales / group_work / group_marketing / group_service / group_insights and neither translated id exists. Refs #3583 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015r31hzuSN19iK8ATRPcCpS
1 parent f860ae5 commit 1d12b62

2 files changed

Lines changed: 153 additions & 22 deletions

File tree

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

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,88 @@ describe('validateTranslationReferences — namespaces deliberately not judged',
401401
});
402402
});
403403

404+
describe('validateTranslationReferences — the canonical view-record shape', () => {
405+
// Reduced from HotCRM: a view record is a CONTAINER whose object binding
406+
// lives inside `list.data.object`, with the named tabs under `listViews`.
407+
// Reading `view.name` / `view.data.object` at the record root resolves
408+
// nothing here, drops the record, and reports every view key the app ships —
409+
// ~40 correct keys on the real corpus.
410+
const leadViews = {
411+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
412+
views: [
413+
{
414+
list: {
415+
type: 'grid',
416+
name: 'all_leads',
417+
data: { provider: 'object', object: 'crm_lead' },
418+
},
419+
listViews: {
420+
my_leads: { name: 'my_leads', type: 'grid', data: { provider: 'object', object: 'crm_lead' } },
421+
kanban_by_status: { name: 'kanban_by_status', type: 'kanban' },
422+
},
423+
formViews: {
424+
default: {
425+
type: 'simple',
426+
data: { provider: 'object', object: 'crm_lead' },
427+
sections: [{ name: 'contact_info', label: 'Contact Info' }],
428+
},
429+
},
430+
},
431+
],
432+
};
433+
434+
it('resolves the default list, every named tab, and a form section', () => {
435+
const findings = validateTranslationReferences({
436+
...leadViews,
437+
translations: [
438+
{
439+
en: {
440+
objects: {
441+
crm_lead: {
442+
label: 'Lead',
443+
_views: {
444+
all_leads: { label: 'All Leads' },
445+
my_leads: { label: 'My Leads' },
446+
kanban_by_status: { label: 'By Status' },
447+
default: { label: 'Default Form' },
448+
},
449+
_sections: { contact_info: { label: 'Contact' } },
450+
},
451+
},
452+
},
453+
},
454+
],
455+
});
456+
expect(findings).toEqual([]);
457+
});
458+
459+
it('still flags a view key no container declares', () => {
460+
const findings = validateTranslationReferences({
461+
...leadViews,
462+
translations: [
463+
{ en: { objects: { crm_lead: { label: 'Lead', _views: { hot_leads: { label: 'Hot' } } } } } },
464+
],
465+
});
466+
expect(findings).toHaveLength(1);
467+
expect(findings[0].path).toBe('translations[0].en.objects.crm_lead._views.hot_leads');
468+
expect(findings[0].hint).toContain('all_leads');
469+
});
470+
471+
it('resolves views embedded on the object itself', () => {
472+
const findings = validateTranslationReferences({
473+
objects: [
474+
{
475+
name: 'crm_lead',
476+
fields: { name: { type: 'text' } },
477+
listViews: { recent: { name: 'recent', type: 'grid' } },
478+
},
479+
],
480+
translations: [{ en: { objects: { crm_lead: { label: 'Lead', _views: { recent: { label: 'Recent' } } } } } }],
481+
});
482+
expect(findings).toEqual([]);
483+
});
484+
});
485+
404486
describe('validateTranslationReferences — section anchors', () => {
405487
it('resolves a section named on a form view or a record:details page', () => {
406488
const stack = {

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

Lines changed: 71 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,70 @@ function emptyFacts(): ObjectFacts {
197197
return { fields: new Map(), views: new Set(), actions: new Map(), sections: new Set() };
198198
}
199199

200-
/** The object a view binds to, across the three shapes views are authored in. */
200+
/**
201+
* Register everything ONE view record contributes: the `_views` names it makes
202+
* legal, and the `_sections` names its form views declare — each under the
203+
* object that container actually binds.
204+
*
205+
* Two things about the real shape make this more than "read `view.name`", and
206+
* both were learned from the HotCRM corpus (~18k lines of shipped metadata),
207+
* where a first pass reported ~40 correct keys as orphans:
208+
*
209+
* 1. A view record is a CONTAINER, not a view. The default list sits at
210+
* `list`; the named tabs at `listViews.<key>` and `formViews.<key>`, each
211+
* of which may also carry its own `name`. Both the map key and the inner
212+
* `name` are accepted — authors write either, and the key is what the
213+
* console renders the tab from.
214+
* 2. The object binding lives INSIDE the container (`list.data.object`), not
215+
* at the record root. A record-level lookup alone resolves to nothing on
216+
* the canonical shape, which silently drops the whole record — a rule that
217+
* then reports every view key the app ships.
218+
*/
219+
function collectViewRecord(view: AnyRec, factsFor: (objectName: string) => ObjectFacts): void {
220+
const recordObject = viewObjectName(view);
221+
const bindingOf = (container: AnyRec): string | undefined =>
222+
viewObjectName(container) ?? recordObject;
223+
224+
const addView = (objectName: string | undefined, name: string | undefined) => {
225+
if (objectName && name) factsFor(objectName).views.add(name);
226+
};
227+
228+
const listBinding = isRec(view.list) ? bindingOf(view.list) : undefined;
229+
if (isRec(view.list)) addView(listBinding, strName(view.list.name));
230+
addView(recordObject ?? listBinding, strName(view.name));
231+
232+
for (const key of ['listViews', 'formViews'] as const) {
233+
const container = view[key];
234+
if (!isRec(container)) continue;
235+
for (const [subKey, sub] of Object.entries(container)) {
236+
if (!isRec(sub)) continue;
237+
const binding = bindingOf(sub) ?? listBinding;
238+
addView(binding, subKey);
239+
addView(binding, strName(sub.name));
240+
241+
// Form sections carry an OPTIONAL `name` that exists purely for the
242+
// `_sections` lookup (`ui/view.zod.ts`: "Stable section identifier for
243+
// i18n lookup"). A section without one cannot be translated at all, so
244+
// it contributes nothing here.
245+
if (binding) {
246+
for (const section of asArray(sub.sections)) {
247+
const sectionName = strName(section.name);
248+
if (sectionName) factsFor(binding).sections.add(sectionName);
249+
}
250+
}
251+
}
252+
}
253+
254+
const sectionBinding = recordObject ?? listBinding;
255+
if (sectionBinding) {
256+
for (const section of asArray(view.sections)) {
257+
const sectionName = strName(section.name);
258+
if (sectionName) factsFor(sectionBinding).sections.add(sectionName);
259+
}
260+
}
261+
}
262+
263+
/** The object a view (or one of its containers) binds to, across the shapes it is authored in. */
201264
function viewObjectName(view: AnyRec): string | undefined {
202265
return (
203266
strName(view.objectName) ??
@@ -269,10 +332,14 @@ function buildUniverse(stack: AnyRec): Universe {
269332
const actionName = strName(action.name);
270333
if (actionName) facts.actions.set(actionName, action);
271334
}
335+
// An object can carry views directly, including the `objects[].listViews`
336+
// container the chart rule also walks. `{ ...view, object: objectName }`
337+
// pins the binding: an embedded view inherits its owner, and nothing here
338+
// depends on the container repeating it.
272339
for (const view of asArray(obj.views)) {
273-
const viewName = strName(view.name);
274-
if (viewName) facts.views.add(viewName);
340+
collectViewRecord({ ...view, object: strName(view.object) ?? objectName }, factsFor);
275341
}
342+
collectViewRecord({ object: objectName, listViews: obj.listViews }, factsFor);
276343
// ADR-0085: `fieldGroups[].key` is the i18n anchor for `_sections`.
277344
for (const group of asArray(obj.fieldGroups)) {
278345
const key = strName(group.key) ?? strName(group.name);
@@ -282,25 +349,7 @@ function buildUniverse(stack: AnyRec): Universe {
282349

283350
// ── Stack-level views: `_views` names + form-section names ──
284351
for (const view of asArray(stack.views)) {
285-
const objectName = viewObjectName(view);
286-
if (!objectName) continue;
287-
const facts = factsFor(objectName);
288-
const viewName = strName(view.name);
289-
if (viewName) facts.views.add(viewName);
290-
291-
// Form sections carry an OPTIONAL `name` that exists purely for this lookup
292-
// (`ui/view.zod.ts`: "Stable section identifier for i18n lookup"). A section
293-
// without one cannot be translated at all, so it adds nothing here.
294-
for (const formView of asArray(view.formViews)) {
295-
for (const section of asArray(formView.sections)) {
296-
const sectionName = strName(section.name);
297-
if (sectionName) facts.sections.add(sectionName);
298-
}
299-
}
300-
for (const section of asArray(view.sections)) {
301-
const sectionName = strName(section.name);
302-
if (sectionName) facts.sections.add(sectionName);
303-
}
352+
collectViewRecord(view, factsFor);
304353
}
305354

306355
// ── Pages: `record:details` sections are the other `_sections` anchor ──

0 commit comments

Comments
 (0)