Skip to content

Commit 7d73667

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23359-uncomposed-security-remedy
2 parents 2dc556b + fba3712 commit 7d73667

5 files changed

Lines changed: 228 additions & 5 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
fix(lint): `page-field-unknown` judges each of a `record:related_list`'s bindings against its own object, so a `dataSource`-bound list no longer reports its parent-side `relationshipValueField` as missing from the child (#23207)
6+
7+
Clause-②: no
8+
9+
- **Child refs** (`columns`, `sort`, `filter`, `relationshipField`, `add.linkField`) resolve against the node-level `dataSource.object` when the list binds its rows that way, else against `properties.objectName`. Before, they resolved only against `properties.objectName`, so a `dataSource`-bound list's child columns went unchecked.
10+
- **The parent ref** (`relationshipValueField`) resolves against the list's `parentDataSource.object` when it declares one, else against the object the list inherits (its container's binding, else the page object). Before, a `dataSource`-bound list checked it against the child, which gave a false `field "…" is not a field on object "CHILD"` warning on a field of the parent.
11+
- **Severity is unchanged.** These findings stay warnings, so default `os validate`, `os lint` and `os build` accept every stack they accepted before. Under `--strict`, which treats warnings as errors, a `dataSource`-bound list with a misspelled child column can now fail a stack that used to pass, because that column was not checked before. To fix it, correct the column name to a field of `dataSource.object`.
12+
- `WalkedComponent` (from `walkPageComponents`) gains an optional `inheritedObject`: the object a component inherits before its own `dataSource.object` / `properties.object` override. `objectName` is unchanged.

‎packages/lint/src/page-walk.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,46 @@ describe('walkPageComponents — object binding precedence', () => {
143143
);
144144
expect(walked.map((w) => w.objectName)).toEqual(['ds_obj', 'ds_obj']);
145145
});
146+
147+
// [#23207] A component's OWN override never leaks into what it inherits:
148+
// `inheritedObject` is the container's binding (else the page object), the
149+
// object a `record:related_list`'s parent-side ref resolves against while
150+
// its own `dataSource` binds the child rows.
151+
it('reports the inherited object apart from the component\'s own override', () => {
152+
const walked = walkPageComponents(
153+
{
154+
object: 'page_obj',
155+
regions: [
156+
{
157+
name: 'main',
158+
components: [
159+
{
160+
type: 'flex',
161+
dataSource: { object: 'ds_obj' },
162+
properties: { children: [{ type: 'leaf', dataSource: { object: 'leaf_obj' } }, { type: 'bare' }] },
163+
},
164+
{ type: 'top', properties: { object: 'prop_obj' } },
165+
],
166+
},
167+
],
168+
},
169+
'pages[0]',
170+
);
171+
expect(walked.map((w) => [w.objectName, w.inheritedObject])).toEqual([
172+
['ds_obj', 'page_obj'],
173+
['leaf_obj', 'ds_obj'],
174+
['ds_obj', 'ds_obj'],
175+
['prop_obj', 'page_obj'],
176+
]);
177+
});
178+
179+
it('inherits nothing on a page that names no object', () => {
180+
const walked = walkPageComponents(
181+
{ regions: [{ name: 'main', components: [{ type: 'a', dataSource: { object: 'ds_obj' } }] }] },
182+
'pages[0]',
183+
);
184+
expect(walked.map((w) => [w.objectName, w.inheritedObject])).toEqual([['ds_obj', undefined]]);
185+
});
146186
});
147187

148188
describe('walkPageComponents — cycle guard', () => {

‎packages/lint/src/page-walk.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ export interface WalkedComponent {
5555
* `undefined` when nothing in the chain names one.
5656
*/
5757
objectName?: string;
58+
/**
59+
* The object this component INHERITS — its container's binding, else the
60+
* page's `object` — before its own `dataSource.object` / `properties.object`
61+
* override is applied. `undefined` when nothing above it names one.
62+
*
63+
* A rule needs it where one component carries two bindings: a
64+
* `record:related_list`'s node-level `dataSource` is its CHILD rows, while
65+
* its `relationshipValueField` names a field of the record it hangs off,
66+
* which is this inherited object (`validate-page-field-bindings`).
67+
*/
68+
inheritedObject?: string;
5869
}
5970

6071
function isRec(v: unknown): v is AnyRec {
@@ -136,7 +147,7 @@ export function walkPageComponents(page: AnyRec, pagePath: string): WalkedCompon
136147
const objectName =
137148
strName(dataSource?.object) ?? strName(props?.object) ?? inheritedObject;
138149

139-
out.push({ component: node, path, objectName });
150+
out.push({ component: node, path, objectName, inheritedObject });
140151

141152
if (!props) return;
142153

‎packages/lint/src/validate-page-field-bindings.test.ts‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,155 @@ describe('validatePageFieldBindings — related lists bind the related object',
365365
});
366366
});
367367

368+
// [#23207] A related list carries two bindings and each batch of refs is judged
369+
// against its own object: the node-level `dataSource` is the CHILD rows (else
370+
// `properties.objectName`), and `relationshipValueField` names a field of the
371+
// PARENT — the object the list inherits (here the page's `crm_lead`).
372+
describe('validatePageFieldBindings — a related list splits its child and parent bindings', () => {
373+
const LIST = 'pages[0].regions[0].components[0].properties';
374+
const findingsOf = (component: Record<string, unknown>) =>
375+
validatePageFieldBindings({ ...baseStack(), pages: [pageWith([{ type: 'record:related_list', ...component }])] })
376+
.map((f) => [f.rule, f.severity, f.path, f.message]);
377+
378+
it('a dataSource-bound list resolves relationshipValueField on the parent: no finding', () => {
379+
// `amount` is a crm_lead (page) field and not a crm_account (child) field.
380+
expect(findingsOf({
381+
dataSource: { object: 'crm_account' },
382+
properties: { relationshipField: 'region', relationshipValueField: 'amount', columns: ['name', 'region'] },
383+
})).toEqual([]);
384+
});
385+
386+
it('a dataSource-bound list checks its child columns against dataSource.object', () => {
387+
expect(findingsOf({
388+
dataSource: { object: 'crm_account' },
389+
properties: { relationshipField: 'region', relationshipValueField: 'amount', columns: ['name', 'status'] },
390+
})).toEqual([[
391+
PAGE_FIELD_UNKNOWN,
392+
'warning',
393+
`${LIST}.columns[1]`,
394+
expect.stringContaining('field "status" is not a field on object "crm_account"'),
395+
]]);
396+
});
397+
398+
it('a dataSource-bound list checks sort, filter and relationshipField against the child too', () => {
399+
expect(findingsOf({
400+
dataSource: { object: 'crm_account' },
401+
properties: {
402+
relationshipField: 'ghost_fk',
403+
sort: [{ field: 'ghost_sort', order: 'asc' }],
404+
filter: [{ field: 'ghost_filter', operator: 'equals', value: 'x' }],
405+
},
406+
}).map(([, , path, message]) => [path, message])).toEqual([
407+
[`${LIST}.sort[0].field`, expect.stringContaining('"ghost_sort" is not a field on object "crm_account"')],
408+
[`${LIST}.filter[0].field`, expect.stringContaining('"ghost_filter" is not a field on object "crm_account"')],
409+
[`${LIST}.relationshipField`, expect.stringContaining('"ghost_fk" is not a field on object "crm_account"')],
410+
]);
411+
});
412+
413+
it('dataSource.object wins over properties.objectName for the child refs', () => {
414+
expect(findingsOf({
415+
dataSource: { object: 'crm_account' },
416+
// `status` is on crm_lead, so naming crm_lead here must not hide the miss.
417+
properties: { objectName: 'crm_lead', relationshipField: 'region', columns: ['status'] },
418+
}).map(([, , path, message]) => [path, message])).toEqual([
419+
[`${LIST}.columns[0]`, expect.stringContaining('"status" is not a field on object "crm_account"')],
420+
]);
421+
});
422+
423+
it('a parent-side ref that really is missing still reports, against the parent, on both spellings', () => {
424+
const expected = [[
425+
PAGE_FIELD_UNKNOWN,
426+
'warning',
427+
`${LIST}.relationshipValueField`,
428+
expect.stringContaining('field "ghost_key" is not a field on object "crm_lead"'),
429+
]];
430+
expect(findingsOf({
431+
dataSource: { object: 'crm_account' },
432+
properties: { relationshipField: 'region', relationshipValueField: 'ghost_key' },
433+
})).toEqual(expected);
434+
expect(findingsOf({
435+
properties: { objectName: 'crm_account', relationshipField: 'region', relationshipValueField: 'ghost_key' },
436+
})).toEqual(expected);
437+
});
438+
439+
it('control: the properties.objectName list keeps its single finding on the bad column', () => {
440+
expect(findingsOf({
441+
properties: { objectName: 'crm_account', relationshipField: 'region', relationshipValueField: 'amount', columns: ['status'] },
442+
})).toEqual([[
443+
PAGE_FIELD_UNKNOWN,
444+
'warning',
445+
`${LIST}.columns[0]`,
446+
expect.stringContaining('field "status" is not a field on object "crm_account"'),
447+
]]);
448+
});
449+
450+
// The parent binding `parentDataSource` (ruling B on objectui#12296): when the
451+
// list declares it, the parent ref resolves on ITS object, not the page's.
452+
it('a list with parentDataSource resolves relationshipValueField on that object: no finding', () => {
453+
// `ext_key` is on crm_par (the declared parent) only: not on crm_lead (the
454+
// page) and not on crm_account (the child rows).
455+
const stack = baseStack();
456+
const findings = validatePageFieldBindings({
457+
objects: [...stack.objects, { name: 'crm_par', fields: { ext_key: { type: 'text' } } }],
458+
pages: [pageWith([{
459+
type: 'record:related_list',
460+
dataSource: { object: 'crm_account' },
461+
properties: { relationshipField: 'region', relationshipValueField: 'ext_key', parentDataSource: { object: 'crm_par' } },
462+
}])],
463+
});
464+
expect(findings).toEqual([]);
465+
});
466+
467+
it('a properties.objectName list with parentDataSource also resolves its parent ref on that object', () => {
468+
// The same reading without a node-level `dataSource`: `ext_key` is on crm_par only.
469+
const stack = baseStack();
470+
const findings = validatePageFieldBindings({
471+
objects: [...stack.objects, { name: 'crm_par', fields: { ext_key: { type: 'text' } } }],
472+
pages: [pageWith([{
473+
type: 'record:related_list',
474+
properties: { objectName: 'crm_account', relationshipField: 'region', relationshipValueField: 'ext_key', parentDataSource: { object: 'crm_par' } },
475+
}])],
476+
});
477+
expect(findings).toEqual([]);
478+
});
479+
480+
it('a list with parentDataSource reports a parent ref its declared parent lacks, against that parent', () => {
481+
// `amount` is a crm_lead (page) field; the declared parent crm_account lacks
482+
// it, and the child rows (crm_account too) must not be what answers.
483+
expect(findingsOf({
484+
dataSource: { object: 'crm_account' },
485+
properties: { relationshipField: 'region', relationshipValueField: 'amount', parentDataSource: { object: 'crm_account' } },
486+
})).toEqual([[
487+
PAGE_FIELD_UNKNOWN,
488+
'warning',
489+
`${LIST}.relationshipValueField`,
490+
expect.stringContaining('field "amount" is not a field on object "crm_account"'),
491+
]]);
492+
});
493+
494+
it('a list nested in a dataSource-bound container resolves its parent ref on the container object', () => {
495+
const findings = validatePageFieldBindings({
496+
...baseStack(),
497+
pages: [pageWith([{
498+
type: 'flex',
499+
dataSource: { object: 'crm_account' },
500+
properties: {
501+
children: [{
502+
type: 'record:related_list',
503+
dataSource: { object: 'crm_lead' },
504+
// `region` is on crm_account (the container), not on crm_lead (the child).
505+
properties: { relationshipField: 'account', relationshipValueField: 'region', columns: ['ghost'] },
506+
}],
507+
},
508+
}])],
509+
});
510+
expect(findings.map((f) => [f.path, f.message])).toEqual([[
511+
`${LIST}.children[0].properties.columns[0]`,
512+
expect.stringContaining('"ghost" is not a field on object "crm_lead"'),
513+
]]);
514+
});
515+
});
516+
368517
describe('validatePageFieldBindings — binding precedence and traversal', () => {
369518
it('dataSource.object overrides the page object', () => {
370519
const findings = validatePageFieldBindings({

‎packages/lint/src/validate-page-field-bindings.ts‎

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -605,18 +605,29 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] {
605605
);
606606
};
607607

608-
for (const { component, path, objectName } of walkPageComponents(page, `pages[${pi}]`)) {
608+
for (const { component, path, objectName, inheritedObject } of walkPageComponents(page, `pages[${pi}]`)) {
609609
const type = strName(component.type);
610610
const props = isRec(component.properties) ? component.properties : undefined;
611611
if (!type || !props) continue;
612612
const where = `page "${pageName}" · ${type}`;
613613
const base = `${path}.properties`;
614614

615615
if (type === RELATED_LIST_TYPE) {
616+
// [#23207] A related list carries TWO object bindings, and each batch
617+
// is judged against its own. The node-level `dataSource` is the CHILD
618+
// rows (its published meaning: the renderer maps `dataSource.object`
619+
// onto `objectName`), so it binds the child-side refs and never the
620+
// parent; without it, `properties.objectName` names the child.
616621
const split = relatedListFieldRefs(props, base);
617-
checkRefs(split.related, split.relatedObject, where);
618-
// `relationshipValueField` names a field on the PARENT (page) object.
619-
checkRefs(split.parent, objectName, where);
622+
const dataSource = isRec(component.dataSource) ? component.dataSource : undefined;
623+
checkRefs(split.related, strName(dataSource?.object) ?? split.relatedObject, where);
624+
// `relationshipValueField` names a field on the PARENT — the record the
625+
// list hangs off, never its own child `dataSource`. The parent is the
626+
// list's own `parentDataSource.object` when it declares one (ruling B on
627+
// objectui#12296, the parent binding #23134 added), else the object the
628+
// list INHERITS: its container's binding, else the page object.
629+
const parentDataSource = isRec(props.parentDataSource) ? props.parentDataSource : undefined;
630+
checkRefs(split.parent, strName(parentDataSource?.object) ?? inheritedObject, where);
620631
// The add-picker resolves against its own object.
621632
checkRefs(split.picker, split.pickerObject, where);
622633
continue;

0 commit comments

Comments
 (0)