Skip to content

Commit 85f9543

Browse files
committed
fix(lint): validateFormLayout resolves a list-bound view's object for its default form
Carries the list-binding fallback rung already used one validator over (validate-translatable-sections.ts:178-185) into validate-form-layout.ts: a container whose object lives only on `list.data.object` (HotCRM's own shape, all 14 of its views) had no object of its own, so `containerObject` resolved to undefined for every site under it — including its own default `form` — and form-field-unknown / form-section-group-unknown never fired there. absolute-colspan-discouraged is unaffected: measured, that check sits outside the known-gated block and was never gated on the object binding. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
1 parent fd75728 commit 85f9543

3 files changed

Lines changed: 119 additions & 2 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
`validateFormLayout` now resolves the bound object for a view container's default `form` (and its `formViews.*` entries that declare no binding of their own) when that container names its object only on the `list` block (`list.data.object`, `list.object` or `list.objectName`) and nowhere on the container itself.
6+
7+
Before this fix, `containerObject` had no way to see a list-only binding, so `objName` stayed `undefined` for every site under such a container — and `form-field-unknown` / `form-section-group-unknown` never fired there, however wrong the section content was. This is the same fallback rung `validate-translatable-sections.ts` already carries for its own sites; it is now shared by both. `absolute-colspan-discouraged` is unaffected by this change — it was never gated on the object binding (it needs only a field's `colSpan`), so it already fired on a list-bound container's form sections before this fix.
8+
9+
Consequence: a view whose object binding lives only on `list` and whose default `form` (or an unbound `formViews.*` entry) references a nonexistent field or an undeclared `section.group` now gets a `warning` finding it did not get before. A stack with no such dangling reference sees no new output.

packages/lint/src/validate-form-layout.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,102 @@ describe('#6251 — reachable on a REAL parsed app stack', () => {
434434
expect(validateFormLayout(value!)).toEqual([]);
435435
});
436436
});
437+
438+
// ───────────────────────────────────────────────────────────────────────────
439+
// #16168 — a container that binds its object only through `list.data.object`
440+
// (HotCRM's own shape, 14/14 views) had NO `object` / `objectName` /
441+
// `data.object` of its own, so `containerObject` — and every site under it,
442+
// including the container's own default `form` — resolved to `undefined` and
443+
// `form-field-unknown` / `form-section-group-unknown` never fired there.
444+
// Fix: fall back to `viewObjectName(view.list)`, the same third rung
445+
// `validate-translatable-sections.ts:178-179` already carries for its sites.
446+
// ───────────────────────────────────────────────────────────────────────────
447+
448+
describe('#16168 — falls back to the container list binding', () => {
449+
/** A container that binds ONLY through `list.data.object` — no `object` / `objectName` at the container root. */
450+
const listBoundContainer = (form: AnyRec) => ({
451+
name: 'contract_views',
452+
list: { label: 'Contracts', type: 'grid', data: { provider: 'object', object: 'contract' }, columns: [{ field: 'name' }] },
453+
form,
454+
});
455+
456+
it('P1 — flags a dangling field in the default `form` of a list-bound container', () => {
457+
const findings = validateFormLayout({
458+
objects,
459+
views: [listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'ghost_field'] }] })],
460+
});
461+
expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([
462+
`${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[1]`,
463+
]);
464+
expect(findings[0].message).toContain('"contract"');
465+
});
466+
467+
it('P2 — flags an undeclared `group` in the default `form` of a list-bound container', () => {
468+
const findings = validateFormLayout({
469+
objects: groupedObjects,
470+
views: [listBoundContainer({ sections: [{ group: 'contact_info' }] })],
471+
});
472+
expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([
473+
`${FORM_SECTION_GROUP_UNKNOWN}@views[0].form.sections[0].group`,
474+
]);
475+
});
476+
477+
it('N1 — positive control: the same defects with `object` also on the container produce the SAME findings (byte-identical where/path)', () => {
478+
const withObject = (view: AnyRec) => ({ ...view, object: 'contract' });
479+
const p1 = validateFormLayout({
480+
objects,
481+
views: [withObject(listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'ghost_field'] }] }))],
482+
});
483+
expect(p1.map((f) => ({ rule: f.rule, where: f.where, path: f.path }))).toEqual([
484+
{ rule: FORM_FIELD_UNKNOWN, where: 'view "contract_views" · form', path: 'views[0].form.sections[0].fields[1]' },
485+
]);
486+
const p2 = validateFormLayout({
487+
objects: groupedObjects,
488+
views: [withObject(listBoundContainer({ sections: [{ group: 'contact_info' }] }))],
489+
});
490+
expect(p2.map((f) => ({ rule: f.rule, where: f.where, path: f.path }))).toEqual([
491+
{ rule: FORM_SECTION_GROUP_UNKNOWN, where: 'view "contract_views" · form', path: 'views[0].form.sections[0].group' },
492+
]);
493+
});
494+
495+
it('N2 — a list-bound container whose form references only real fields is clean', () => {
496+
expect(validateFormLayout({
497+
objects,
498+
views: [listBoundContainer({ sections: [{ columns: 2, fields: ['name', 'amount'] }] })],
499+
})).toEqual([]);
500+
});
501+
502+
it('N3 — a `formViews[]` entry with its own `objectName` resolves as today, independent of the container', () => {
503+
const findings = validateFormLayout({
504+
objects,
505+
views: [{
506+
name: 'contract_views',
507+
list: { data: { object: 'contract' } },
508+
formViews: { edit: { objectName: 'contract', sections: [{ fields: ['ghost_via_own_binding'] }] } },
509+
}],
510+
});
511+
expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([
512+
`${FORM_FIELD_UNKNOWN}@views[0].formViews.edit.sections[0].fields[0]`,
513+
]);
514+
});
515+
516+
it('N4 — a view with neither a container nor a list binding stays silent (unchanged: the rule cannot check what it cannot resolve)', () => {
517+
expect(validateFormLayout({
518+
objects,
519+
views: [{ name: 'orphan_views', form: { sections: [{ fields: ['whatever'] }] } }],
520+
})).toEqual([]);
521+
});
522+
523+
it('the colSpan rule is unconditional on the object binding — it already fired on a list-bound container before this fix', () => {
524+
// Measured for the changeset: `absolute-colspan-discouraged` sits OUTSIDE
525+
// the `known`-gated block (validate-form-layout.ts, the `(b)` comment) —
526+
// it needs only `entry.colSpan != null`, never `objName` / `known`. So it
527+
// was never actually dead on HotCRM's list-bound views; #16168 only fixes
528+
// `form-field-unknown` and `form-section-group-unknown`.
529+
const findings = validateFormLayout({
530+
objects,
531+
views: [listBoundContainer({ sections: [{ columns: 2, fields: [{ field: 'name', colSpan: 2 }] }] })],
532+
});
533+
expect(findings.map((f) => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]);
534+
});
535+
});

packages/lint/src/validate-form-layout.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,16 +130,25 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] {
130130
// artifact-emitted one may carry neither, so the path is the last resort.
131131
const viewName = strName(view.name) ?? strName(view.object) ?? viewPath;
132132
const containerObject = viewObjectName(view);
133+
// [#16168] A container whose object lives on the LIST block
134+
// (`list.data.object`, `list.object` or `list.objectName` — any anchor
135+
// `viewObjectName` reads) has no `object` / `objectName` / `data.object` of
136+
// its own, so `containerObject` above is `undefined` and every site under it
137+
// — including the container's own default `form` — was unreferenceable.
138+
// Same third rung `validate-translatable-sections.ts:178-179` already
139+
// carries for its own sites.
140+
const listBinding = isRec(view.list) ? viewObjectName(view.list) ?? containerObject : undefined;
133141

134142
for (const site of formViewSites(view, viewPath)) {
135143
// A sub-container declares its own binding (`form.data.object`) and
136-
// otherwise inherits the container's — the resolution order every other
144+
// otherwise inherits the container's, then the container's list-bound
145+
// object as a last resort (#16168) — the resolution order every other
137146
// view-walking rule in this package uses. The base rung is the shared
138147
// `viewObjectName` (#6662); this FALLBACK is deliberately NOT folded into
139148
// the shared walker, because the consumers compose it differently (see
140149
// `view-walk.ts`) and a refactor that changes a verdict is a failed
141150
// refactor.
142-
const objName = viewObjectName(site.view) ?? containerObject;
151+
const objName = viewObjectName(site.view) ?? containerObject ?? listBinding;
143152
// Only reference-check when the bound object resolves; otherwise we can't.
144153
const known = objName ? objectFields.get(objName) : undefined;
145154
const where = site.surface ? `view "${viewName}" · ${site.surface}` : `view "${viewName}"`;

0 commit comments

Comments
 (0)