Skip to content
5 changes: 5 additions & 0 deletions .changeset/lint-preset-comparand-field-typed-arm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/lint': minor
---

`filter-preset-comparand` gains a FIELD-TYPED arm (#16106, maintainer-ruled 1′): on a declared `date` / `datetime` field, a dashboard date-range preset name (`last_30_days`, `this_quarter`, …) is now refused in EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / `$nin`, and their view-rule (`equals` / `not_equals` / `in` / `not_in`) and triple (`=` / `!=` / `in` / `nin`) spellings — with the same located message and prescription the ordering positions already carry (`{ $gte: '{30_days_ago}' }` for `last_30_days`, and so on). The field type is read from the stack's own object graph: a dashboard widget or report through its `dataset` to that dataset's `object`, a view through `data.object`, a flow CRUD node through `config.objectName`, a page component through `dataSource` / `properties`, an object's own list views and `relatedListFilter`, a summary field's child object. A position the graph cannot bind, a registry-injected column, a `time` field, or a select / text column stays unjudged — equality against a picklist value that collides with a preset name is a working filter. The field-agnostic schema door in `@objectstack/spec` keeps its ordering-only boundary unchanged; this closes the authoring-time gap where `objectstack lint` and the runtime publish gate accepted a filter the engine then refused with `INVALID_FILTER` / 400 on first render.
5 changes: 5 additions & 0 deletions .changeset/spec-preset-comparand-message-tsdoc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': minor
---

`bareDateRangePresetComparandMessage`'s TSDoc — published in `dist/*.d.ts` — now states both moments the wording is reported at: the field-agnostic schema door in `data/filter.zod.ts` (ordering positions only: without a field type, equality on a select column is legitimate) and `@objectstack/lint`'s `filter-preset-comparand` rule, which with the field type in hand refuses every comparand position on a declared `date` / `datetime` field (#16106). The message text itself is unchanged.
18 changes: 11 additions & 7 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -608,13 +608,17 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// no layer: the engine refuses it on a declared temporal field at query time
// (INVALID_FILTER / 400, PR #8808), and anywhere else it compares as a
// literal string. This is the authoring-time refusal the ruling shipped
// alongside the engine door, judging the filter literal in isolation —
// ordering positions only, all three authored filter shapes. Like
// `validateEmptyCombinators` it needs NO resolution context, so
// RUNTIME_NEEDS_FULL_SNAPSHOT does not apply and the runtime gate runs it
// for every filter-carrying type the gate already maps: the write path is
// the one door an AI author uses, and dashboards/views are where the preset
// vocabulary is near enough to reach for.
// alongside the engine door. Two arms (the rule's header is the authority):
// arm 1 judges the filter literal in isolation — ordering positions only,
// all three authored filter shapes, no resolution context; arm 2 (#16106,
// maintainer-ruled 1′) judges equality / membership positions WITH the field
// type in hand, read from the stack's own `objects` (and `datasets`, to bind
// a widget or report) — both collections the per-write snapshot carries —
// and stays silent wherever they are absent. So RUNTIME_NEEDS_FULL_SNAPSHOT
// still does not apply and the runtime gate runs it for every
// filter-carrying type the gate already maps: the write path is the one door
// an AI author uses, and dashboards/views are where the preset vocabulary is
// near enough to reach for.
{
name: 'validatePresetComparands',
tier: 'gating',
Expand Down
307 changes: 307 additions & 0 deletions packages/lint/src/validate-preset-comparands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,310 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () =>
})).toEqual([]);
});
});

describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / membership refusal (#16106)', () => {
/**
* The card's own shape: `crm_opportunity` declares `close_date` as a `date`
* column (plus a `datetime` sibling, a `time` sibling, a select column whose
* option value collides with a preset name, a text column, and a lookup hop
* to an object with its own date column).
*/
const crmObjects = [
{
name: 'crm_opportunity',
fields: {
close_date: { type: 'date' },
closed_at: { type: 'datetime' },
opens_at: { type: 'time' },
stage: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] },
period: { type: 'text' },
account: { type: 'lookup', reference: 'crm_account' },
},
},
{ name: 'crm_account', fields: { created_on: { type: 'date' }, name: { type: 'text' } } },
{ name: 'crm_note', fields: { opportunity: { type: 'lookup', reference: 'crm_opportunity' }, noted_on: { type: 'date' } } },
];
const crmDatasets = [{ name: 'deals', object: 'crm_opportunity', measures: [] }];

const board = (widgets: unknown[]) => ({
objects: crmObjects,
datasets: crmDatasets,
dashboards: [{ name: 'sales', widgets }],
});
const widget = (id: string, filter: unknown, over: Record<string, unknown> = {}) => ({
id, type: 'metric', dataset: 'deals', values: ['total'], filter, ...over,
});

it("refuses the card's three residue rows — bare, $eq, $in — on a declared date field, naming path and window", () => {
const findings = validatePresetComparands(board([
widget('bare', { close_date: 'last_30_days' }),
widget('eq', { close_date: { $eq: 'last_30_days' } }),
widget('in', { close_date: { $in: ['last_30_days'] } }),
]));
expect(findings.map((f) => f.path).sort()).toEqual([
'dashboards[0].widgets[0].filter.close_date',
'dashboards[0].widgets[1].filter.close_date.$eq',
'dashboards[0].widgets[2].filter.close_date.$in[0]',
]);
for (const f of findings) {
expect(f.severity).toBe('error');
expect(f.rule).toBe(FILTER_PRESET_COMPARAND);
expect(f.where).toMatch(/^dashboard "sales" · widget "/);
expect(f.message).toContain('"last_30_days" is a dashboard date-range PRESET name');
expect(f.message).toContain('{30_days_ago}'); // the spelling that works
expect(f.hint).toContain('{date-macro}');
}
// The implicit-equality position is reported under the operator it lowers to.
expect(findings[0].message).toContain('As a bare "$eq" comparand');
expect(findings[2].message).toContain('As a bare "$in" comparand');
});

it('judges $ne / $nin, every member of a list, and a declared datetime field the same way', () => {
const findings = validatePresetComparands(board([
widget('w', {
close_date: { $ne: 'yesterday' },
closed_at: { $nin: ['last_week', '2026-01-01', 'this_year'] },
}),
]));
expect(findings.map((f) => f.path).sort()).toEqual([
'dashboards[0].widgets[0].filter.close_date.$ne',
'dashboards[0].widgets[0].filter.closed_at.$nin[0]',
'dashboards[0].widgets[0].filter.closed_at.$nin[2]',
]);
});

it('judges the view-rule and triple spellings, alias folds included', () => {
const findings = validatePresetComparands({
objects: crmObjects,
views: [{
name: 'recent',
data: { provider: 'object', object: 'crm_opportunity' },
filter: [
{ field: 'close_date', operator: 'equals', value: 'last_30_days' },
{ field: 'close_date', operator: 'eq', value: 'this_month' }, // alias → equals
{ field: 'closed_at', operator: 'in', value: ['last_7_days', '2026-01-01'] },
{ field: 'close_date', operator: 'notIn', value: ['last_week'] }, // alias → not_in
{ field: 'close_date', operator: 'ne', value: 'today' }, // alias → not_equals
],
}],
pages: [{
name: 'board',
components: [
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', '=', 'last_30_days'] },
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['and', ['closed_at', 'in', ['this_year']], ['close_date', '!=', 'yesterday']] },
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', 'nin', ['last_quarter']] },
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', 'equals', 'this_week'] }, // alias → =
],
}],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'pages[0].components[0].filter[2]',
'pages[0].components[1].filter[1][2][0]',
'pages[0].components[1].filter[2][2]',
'pages[0].components[2].filter[2][0]',
'pages[0].components[3].filter[2]',
'views[0].filter[0].value',
'views[0].filter[1].value',
'views[0].filter[2].value[0]',
'views[0].filter[3].value[0]',
'views[0].filter[4].value',
].sort());
// The view rule reports the canonical operator, the triple the authored one.
expect(findings.find((f) => f.path === 'views[0].filter[1].value')!.message).toContain('"equals"');
expect(findings.find((f) => f.path === 'pages[0].components[3].filter[2]')!.message).toContain('"equals"');
});

it('binds every carrier to the object its conditions address, nearest declaration first', () => {
const findings = validatePresetComparands({
objects: [
...crmObjects.slice(1),
{
name: 'crm_opportunity',
fields: {
...crmObjects[0].fields,
// A summary field's `filter` runs over the CHILD object (its own `object`).
note_count: { type: 'summary', summary: { object: 'crm_note', function: 'count', filter: { noted_on: 'last_month' } } },
// A `relatedListFilter` runs over the rows of the object that owns the field.
account: { type: 'lookup', reference: 'crm_account', relatedListFilter: { close_date: { $in: ['this_quarter'] } } },
},
// An object's own list views: the object itself.
listViews: [{ name: 'closing', filter: { close_date: 'this_week' } }],
},
],
datasets: crmDatasets,
reports: [{
name: 'pipeline', dataset: 'deals',
runtimeFilter: { close_date: { $eq: 'last_quarter' } },
blocks: [{ type: 'table', dataset: 'deals', runtimeFilter: { closed_at: 'last_year' } }],
}],
flows: [{
name: 'sweep',
nodes: [{ id: 'find', type: 'find_records', config: { objectName: 'crm_opportunity', filter: { close_date: { $ne: 'today' } } } }],
}],
pages: [{
name: 'detail', object: 'crm_account',
components: [
// `record:related_list`: `properties.objectName` is the related object.
{ type: 'record:related_list', properties: { objectName: 'crm_opportunity', filter: { close_date: 'last_7_days' } } },
// No component binding: the page's own `object`.
{ type: 'list', filter: { created_on: 'last_90_days' } },
],
}],
dashboards: [{
name: 'ops',
globalFilters: [{ name: 'acct', field: 'account', type: 'select', optionsFrom: { object: 'crm_account', valueField: 'id', labelField: 'name', filter: { created_on: 'this_year' } } }],
widgets: [
// A relationship hop: nested condition AND dotted spelling, both resolved on `crm_account`.
widget('hop', { account: { created_on: 'last_month' }, 'account.created_on': { $in: ['this_month'] } }),
],
}],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'dashboards[0].globalFilters[0].optionsFrom.filter.created_on',
'dashboards[0].widgets[0].filter.account.created_on',
'dashboards[0].widgets[0].filter.account.created_on.$in[0]',
'flows[0].nodes[0].config.filter.close_date.$ne',
'objects[2].fields.account.relatedListFilter.close_date.$in[0]',
'objects[2].fields.note_count.summary.filter.noted_on',
'objects[2].listViews[0].filter.close_date',
'pages[0].components[0].properties.filter.close_date',
'pages[0].components[1].filter.created_on',
'reports[0].blocks[0].runtimeFilter.closed_at',
'reports[0].runtimeFilter.close_date.$eq',
].sort());
});

it('binds name-keyed (map-form) objects and datasets exactly as array-form ones', () => {
const findings = validatePresetComparands({
objects: { crm_opportunity: { fields: { close_date: { type: 'date' } } } },
datasets: { deals: { object: 'crm_opportunity', measures: [] } },
dashboards: [{ name: 'sales', widgets: [widget('w', { close_date: 'last_30_days' })] }],
});
expect(findings.map((f) => f.path)).toEqual(['dashboards[0].widgets[0].filter.close_date']);
});

it('stays quiet on every legitimate or unjudgeable position — the discriminating controls', () => {
expect(validatePresetComparands({
objects: [
...crmObjects,
{ name: 'crm_empty' }, // declares no field map
],
datasets: [
...crmDatasets,
{ name: 'ghost_ds', object: 'no_such_object', measures: [] },
],
dashboards: [{
name: 'ok',
widgets: [
widget('picklist', {
// Equality / membership against a select or text column whose
// value collides with a preset name is the author's own vocabulary.
stage: 'this_quarter',
period: { $in: ['last_30_days'] },
// A `time` column: the ruling names date / datetime only.
opens_at: 'today',
// A registry-injected column: its type is invisible to the graph.
created_at: 'last_30_days',
// A field the object does not declare: another rule's finding.
close_dat: 'last_30_days',
// The platform's own correct spellings in the judged positions.
close_date: { $in: ['{30_days_ago}', '2026-01-15'] },
closed_at: { $eq: '2026-01-15T00:00:00.000Z' },
}),
// Bindings that cannot resolve: an unknown dataset, a dataset on an
// unknown object, an object with no field map.
widget('ghost', { close_date: 'last_30_days' }, { dataset: 'no_such_dataset' }),
widget('ghost2', { close_date: 'last_30_days' }, { dataset: 'ghost_ds' }),
],
}],
views: [
// A non-`object` provider names no fields on any object graph.
{ name: 'api', data: { provider: 'api', object: 'crm_opportunity' }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] },
// No binding at all.
{ name: 'unbound', filter: [{ field: 'close_date', operator: 'in', value: ['last_30_days'] }] },
],
flows: [{
name: 'templated',
// A templated target object is resolved at run time — skipped, not guessed.
nodes: [{ id: 'n', config: { objectName: '{vars.target}', filter: { close_date: 'last_30_days' } } }],
}],
apps: [{ name: 'crm', filter: { close_date: 'last_30_days' } }],
pages: [{ name: 'p', components: [{ type: 'list', filter: ['close_date', '=', 'last_30_days'] }] }],
})).toEqual([]);
});

// [#16106 review finding B1] A form field's `publicPicker.filter` is a static
// pre-filter the public-lookup route runs on the REFERENCED object
// (`picker.object`, else the field's `reference`). Binding it to the view's
// own object produced a FALSE refusal — the one failure direction this arm
// may never have — whenever the parent and the referenced object share a
// field name with differing types.
const pickerObjects = [
{
name: 'crm_opportunity',
fields: {
close_date: { type: 'date' },
account: { type: 'lookup', reference: 'crm_account' },
contact: { type: 'lookup', reference: 'crm_contact' },
owner_note: { type: 'text' },
},
},
// Same field NAME as the parent, a select column whose option value collides with a preset.
{ name: 'crm_account', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } },
// Same field name, genuinely a date.
{ name: 'crm_contact', fields: { close_date: { type: 'date' } } },
];
const pickerForm = (fields: unknown[]) => ({
objects: pickerObjects,
views: [{
name: 'lead_form', type: 'form',
data: { provider: 'object', object: 'crm_opportunity' },
sections: [{ fields }],
}],
});
const pickerRule = { field: 'close_date', operator: 'equals', value: 'this_quarter' };

it('[B1] stays QUIET on a publicPicker filter over a referenced select column that shares its name with a parent date column', () => {
// The measured false refusal: parent `close_date` is a date, the picker queries `crm_account`.
expect(validatePresetComparands(pickerForm([
{ field: 'account', publicPicker: { filter: [pickerRule] } },
]))).toEqual([]);
// The `object` override names the referenced object outright.
expect(validatePresetComparands(pickerForm([
{ field: 'account', publicPicker: { object: 'crm_account', filter: [pickerRule] } },
]))).toEqual([]);
// Unresolvable pickers stay UNJUDGED, never the parent: a field the form
// object does not declare, and a field that is not a relationship (no
// `reference` to follow — on the parent it would have read as a date).
expect(validatePresetComparands(pickerForm([
{ field: 'no_such_field', publicPicker: { filter: [pickerRule] } },
{ field: 'close_date', publicPicker: { filter: [pickerRule] } },
{ field: 'owner_note', publicPicker: { filter: [pickerRule] } },
]))).toEqual([]);
});

it('[B1] POSITIVE CONTROL: the same picker filter is still refused when the REFERENCED object declares the field as a date', () => {
// Resolved through the field's `reference`.
expect(validatePresetComparands(pickerForm([
{ field: 'contact', publicPicker: { filter: [pickerRule] } },
])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']);
// Resolved through the `object` override (pointing a select-typed parent lookup at the date object).
expect(validatePresetComparands(pickerForm([
{ field: 'account', publicPicker: { object: 'crm_contact', filter: [pickerRule] } },
])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']);
});

it('keeps arm 1 field-agnostic: an ordering preset still fires with NO objects in the stack, and on a text column', () => {
const findings = validatePresetComparands({
objects: crmObjects,
datasets: crmDatasets,
dashboards: [{ name: 'd', widgets: [widget('w', { period: { $gte: 'last_30_days' }, close_date: 'last_30_days' })] }],
views: [{ name: 'v', filter: [{ field: 'anything', operator: 'after', value: 'last_7_days' }] }],
});
expect(findings.map((f) => f.path).sort()).toEqual([
'dashboards[0].widgets[0].filter.close_date',
'dashboards[0].widgets[0].filter.period.$gte',
'views[0].filter[0].value',
]);
});
});
Loading
Loading