diff --git a/.changeset/20400-datasource-waiver-missing-only.md b/.changeset/20400-datasource-waiver-missing-only.md new file mode 100644 index 00000000000..d9d2723b4f4 --- /dev/null +++ b/.changeset/20400-datasource-waiver-missing-only.md @@ -0,0 +1,27 @@ +--- +'@objectstack/lint': patch +--- + +`component-props-invalid` no longer hides a wrong `object` prop on a component that also carries a `dataSource` binding + +Clause-②: no + +A page component whose `dataSource.object` names the object may leave the flat +`properties.object` shorthand out: the binding supplies it, so the rule does not +report the props schema's required `object` as missing. That waiver was matched +on the issue's path alone, so it also swallowed every other issue the props +schema raised at `object`. A present but wrong value, such as `object: 7` or +`object: null`, was reported without a binding and silently passed with one. + +The waiver now covers what its contract says: a missing `object`, meaning no +key or an explicit `undefined`. A value the author did write is judged as +written, and it is reported at `properties.object` exactly as it is on the same +component without a binding. + +Effect on `os validate`, `os lint` and `os build`: a document that sets both a +`dataSource` binding and a wrong-typed `properties.object` now gets one +`component-props-invalid` warning it did not get before. The rule stays +advisory: without `--strict` nothing that validated before is refused; under +`os validate --strict` or `os lint --strict` the new warning fails the run, as +every warning does. A component that binds +through `dataSource` and omits `properties.object` is still clean. diff --git a/packages/lint/src/validate-component-props.test.ts b/packages/lint/src/validate-component-props.test.ts index 776b8b641df..96035d4f925 100644 --- a/packages/lint/src/validate-component-props.test.ts +++ b/packages/lint/src/validate-component-props.test.ts @@ -231,6 +231,58 @@ describe('validateComponentProps — value verdicts', () => { ]); }); + /** + * The waiver above covers a MISSING `object` only — no key, or `undefined`. + * A present value the row rejects is the author's own, and the binding + * supplies nothing in its place, so the row's verdict on it must reach the + * author exactly as it does with no binding at all. Each case is judged + * twice, beside the binding and without it, and the two answers must be the + * same finding: equality rather than wording, so the pin measures that the + * waiver lets the row's issue through unchanged. + */ + it.each([ + ['a number', 7], + ['null', null], + ])('reports a present-but-wrong `object` (%s) beside a `dataSource` binding, as it does without one', (_label, value) => { + const component = { type: 'element:number', properties: { object: value, aggregate: 'count' } }; + const withBinding = validateComponentProps( + stackWith([{ ...component, dataSource: { object: 'contact' } }]), + ); + const without = validateComponentProps(stackWith([component])); + + // Control first: with nothing supplying `object`, the row reports it. + expect(without.map((f) => [f.rule, f.path])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], + ]); + // The binding does not silence it. + expect(withBinding.map((f) => [f.rule, f.path])).toEqual([ + [COMPONENT_PROPS_INVALID, 'pages[0].regions[0].components[0].properties.object'], + ]); + expect(withBinding).toEqual(without); + }); + + it('still waives `object: undefined` beside a binding — an explicit undefined is a missing value', () => { + const findings = validateComponentProps( + stackWith([ + { + type: 'element:number', + dataSource: { object: 'contact' }, + properties: { object: undefined, aggregate: 'count' }, + }, + ]), + ); + expect(findings).toEqual([]); + + // …and the same bag with no binding is reported, so the silence above is + // the waiver and not the row accepting `undefined`. + const without = validateComponentProps( + stackWith([{ type: 'element:number', properties: { object: undefined, aggregate: 'count' } }]), + ); + expect(invalid(without).map((f) => f.path)).toEqual([ + 'pages[0].regions[0].components[0].properties.object', + ]); + }); + /** * #5775 — the retirement's author-facing channel. `displayField` was the * picker's REQUIRED prop and no renderer ever read it, so the tombstone's diff --git a/packages/lint/src/validate-component-props.ts b/packages/lint/src/validate-component-props.ts index 2b6bcb7bdae..f4793c57828 100644 --- a/packages/lint/src/validate-component-props.ts +++ b/packages/lint/src/validate-component-props.ts @@ -175,7 +175,13 @@ const DATASOURCE_SUPPLIED_PROP = 'object'; function suppliedByDataSource(issue: LintZodIssue, component: AnyRec): boolean { if (issue.path.length !== 1 || issue.path[0] !== DATASOURCE_SUPPLIED_PROP) return false; const dataSource = isRec(component.dataSource) ? component.dataSource : undefined; - return strName(dataSource?.object) !== undefined; + if (strName(dataSource?.object) === undefined) return false; + // "Missing" is read off the component, never off the issue: the path alone + // also matches a PRESENT value the row rejects (`object: 7`, `object: null`), + // and the binding supplies nothing there — the author wrote that value and + // the row's own verdict on it stands. Only no key, or `undefined`, is waived. + const props = isRec(component.properties) ? component.properties : undefined; + return props?.[DATASOURCE_SUPPLIED_PROP] === undefined; } /**