Skip to content

Commit 6fa48b9

Browse files
huangyiireneclaude
andauthored
feat(lint): field-typed equality/membership arm for filter-preset-comparand on declared date/datetime fields (#16347)
* feat(lint): field-typed equality/membership arm for filter-preset-comparand On a declared date / datetime field, refuse a dashboard date-range preset name in every comparand position (bare, $eq/$ne, $in/$nin, and the view-rule and triple spellings), binding each authored filter to the object its conditions address through the stack's own object graph. The field-agnostic schema door keeps its ordering-only boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * docs(lint,spec): describe the field-typed arm at the registration and in the shared message's TSDoc The rule's registration comment said it needs no resolution context; arm 2 reads the stack's objects and datasets when present. The shared refusal wording's TSDoc said "ordering comparand"; it is now also the wording for the field-typed equality/membership positions. The TSDoc publishes in dist/*.d.ts (measured), so the spec change carries a patch changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * chore(changeset): grade the spec TSDoc changeset minor for the clause-② gate Check Changeset reads clause-② at PR level and requires at least minor on every package the PR grows. The spec diff is a TSDoc comment only; the public-surface widening of this PR is in @objectstack/lint, already minor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno * fix(lint): bind a form field's publicPicker.filter to the referenced object, never the parent form The public-lookup route runs a publicPicker filter on the referenced object (picker.object, else the field's reference). The field-typed arm's binding fell through to the view's own object, so a parent date column and a referenced select column sharing a name produced a false refusal on a legitimate picker filter. The picker is now a claiming reader: it resolves the referenced object or leaves the position unjudged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0290579 commit 6fa48b9

6 files changed

Lines changed: 737 additions & 37 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`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.

packages/lint/src/authoring-rules.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -608,13 +608,17 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
608608
// no layer: the engine refuses it on a declared temporal field at query time
609609
// (INVALID_FILTER / 400, PR #8808), and anywhere else it compares as a
610610
// literal string. This is the authoring-time refusal the ruling shipped
611-
// alongside the engine door, judging the filter literal in isolation —
612-
// ordering positions only, all three authored filter shapes. Like
613-
// `validateEmptyCombinators` it needs NO resolution context, so
614-
// RUNTIME_NEEDS_FULL_SNAPSHOT does not apply and the runtime gate runs it
615-
// for every filter-carrying type the gate already maps: the write path is
616-
// the one door an AI author uses, and dashboards/views are where the preset
617-
// vocabulary is near enough to reach for.
611+
// alongside the engine door. Two arms (the rule's header is the authority):
612+
// arm 1 judges the filter literal in isolation — ordering positions only,
613+
// all three authored filter shapes, no resolution context; arm 2 (#16106,
614+
// maintainer-ruled 1′) judges equality / membership positions WITH the field
615+
// type in hand, read from the stack's own `objects` (and `datasets`, to bind
616+
// a widget or report) — both collections the per-write snapshot carries —
617+
// and stays silent wherever they are absent. So RUNTIME_NEEDS_FULL_SNAPSHOT
618+
// still does not apply and the runtime gate runs it for every
619+
// filter-carrying type the gate already maps: the write path is the one door
620+
// an AI author uses, and dashboards/views are where the preset vocabulary is
621+
// near enough to reach for.
618622
{
619623
name: 'validatePresetComparands',
620624
tier: 'gating',

packages/lint/src/validate-preset-comparands.test.ts

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,310 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () =>
192192
})).toEqual([]);
193193
});
194194
});
195+
196+
describe('validatePresetComparands — arm 2, the FIELD-TYPED equality / membership refusal (#16106)', () => {
197+
/**
198+
* The card's own shape: `crm_opportunity` declares `close_date` as a `date`
199+
* column (plus a `datetime` sibling, a `time` sibling, a select column whose
200+
* option value collides with a preset name, a text column, and a lookup hop
201+
* to an object with its own date column).
202+
*/
203+
const crmObjects = [
204+
{
205+
name: 'crm_opportunity',
206+
fields: {
207+
close_date: { type: 'date' },
208+
closed_at: { type: 'datetime' },
209+
opens_at: { type: 'time' },
210+
stage: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] },
211+
period: { type: 'text' },
212+
account: { type: 'lookup', reference: 'crm_account' },
213+
},
214+
},
215+
{ name: 'crm_account', fields: { created_on: { type: 'date' }, name: { type: 'text' } } },
216+
{ name: 'crm_note', fields: { opportunity: { type: 'lookup', reference: 'crm_opportunity' }, noted_on: { type: 'date' } } },
217+
];
218+
const crmDatasets = [{ name: 'deals', object: 'crm_opportunity', measures: [] }];
219+
220+
const board = (widgets: unknown[]) => ({
221+
objects: crmObjects,
222+
datasets: crmDatasets,
223+
dashboards: [{ name: 'sales', widgets }],
224+
});
225+
const widget = (id: string, filter: unknown, over: Record<string, unknown> = {}) => ({
226+
id, type: 'metric', dataset: 'deals', values: ['total'], filter, ...over,
227+
});
228+
229+
it("refuses the card's three residue rows — bare, $eq, $in — on a declared date field, naming path and window", () => {
230+
const findings = validatePresetComparands(board([
231+
widget('bare', { close_date: 'last_30_days' }),
232+
widget('eq', { close_date: { $eq: 'last_30_days' } }),
233+
widget('in', { close_date: { $in: ['last_30_days'] } }),
234+
]));
235+
expect(findings.map((f) => f.path).sort()).toEqual([
236+
'dashboards[0].widgets[0].filter.close_date',
237+
'dashboards[0].widgets[1].filter.close_date.$eq',
238+
'dashboards[0].widgets[2].filter.close_date.$in[0]',
239+
]);
240+
for (const f of findings) {
241+
expect(f.severity).toBe('error');
242+
expect(f.rule).toBe(FILTER_PRESET_COMPARAND);
243+
expect(f.where).toMatch(/^dashboard "sales" · widget "/);
244+
expect(f.message).toContain('"last_30_days" is a dashboard date-range PRESET name');
245+
expect(f.message).toContain('{30_days_ago}'); // the spelling that works
246+
expect(f.hint).toContain('{date-macro}');
247+
}
248+
// The implicit-equality position is reported under the operator it lowers to.
249+
expect(findings[0].message).toContain('As a bare "$eq" comparand');
250+
expect(findings[2].message).toContain('As a bare "$in" comparand');
251+
});
252+
253+
it('judges $ne / $nin, every member of a list, and a declared datetime field the same way', () => {
254+
const findings = validatePresetComparands(board([
255+
widget('w', {
256+
close_date: { $ne: 'yesterday' },
257+
closed_at: { $nin: ['last_week', '2026-01-01', 'this_year'] },
258+
}),
259+
]));
260+
expect(findings.map((f) => f.path).sort()).toEqual([
261+
'dashboards[0].widgets[0].filter.close_date.$ne',
262+
'dashboards[0].widgets[0].filter.closed_at.$nin[0]',
263+
'dashboards[0].widgets[0].filter.closed_at.$nin[2]',
264+
]);
265+
});
266+
267+
it('judges the view-rule and triple spellings, alias folds included', () => {
268+
const findings = validatePresetComparands({
269+
objects: crmObjects,
270+
views: [{
271+
name: 'recent',
272+
data: { provider: 'object', object: 'crm_opportunity' },
273+
filter: [
274+
{ field: 'close_date', operator: 'equals', value: 'last_30_days' },
275+
{ field: 'close_date', operator: 'eq', value: 'this_month' }, // alias → equals
276+
{ field: 'closed_at', operator: 'in', value: ['last_7_days', '2026-01-01'] },
277+
{ field: 'close_date', operator: 'notIn', value: ['last_week'] }, // alias → not_in
278+
{ field: 'close_date', operator: 'ne', value: 'today' }, // alias → not_equals
279+
],
280+
}],
281+
pages: [{
282+
name: 'board',
283+
components: [
284+
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', '=', 'last_30_days'] },
285+
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['and', ['closed_at', 'in', ['this_year']], ['close_date', '!=', 'yesterday']] },
286+
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', 'nin', ['last_quarter']] },
287+
{ type: 'list', dataSource: { object: 'crm_opportunity' }, filter: ['close_date', 'equals', 'this_week'] }, // alias → =
288+
],
289+
}],
290+
});
291+
expect(findings.map((f) => f.path).sort()).toEqual([
292+
'pages[0].components[0].filter[2]',
293+
'pages[0].components[1].filter[1][2][0]',
294+
'pages[0].components[1].filter[2][2]',
295+
'pages[0].components[2].filter[2][0]',
296+
'pages[0].components[3].filter[2]',
297+
'views[0].filter[0].value',
298+
'views[0].filter[1].value',
299+
'views[0].filter[2].value[0]',
300+
'views[0].filter[3].value[0]',
301+
'views[0].filter[4].value',
302+
].sort());
303+
// The view rule reports the canonical operator, the triple the authored one.
304+
expect(findings.find((f) => f.path === 'views[0].filter[1].value')!.message).toContain('"equals"');
305+
expect(findings.find((f) => f.path === 'pages[0].components[3].filter[2]')!.message).toContain('"equals"');
306+
});
307+
308+
it('binds every carrier to the object its conditions address, nearest declaration first', () => {
309+
const findings = validatePresetComparands({
310+
objects: [
311+
...crmObjects.slice(1),
312+
{
313+
name: 'crm_opportunity',
314+
fields: {
315+
...crmObjects[0].fields,
316+
// A summary field's `filter` runs over the CHILD object (its own `object`).
317+
note_count: { type: 'summary', summary: { object: 'crm_note', function: 'count', filter: { noted_on: 'last_month' } } },
318+
// A `relatedListFilter` runs over the rows of the object that owns the field.
319+
account: { type: 'lookup', reference: 'crm_account', relatedListFilter: { close_date: { $in: ['this_quarter'] } } },
320+
},
321+
// An object's own list views: the object itself.
322+
listViews: [{ name: 'closing', filter: { close_date: 'this_week' } }],
323+
},
324+
],
325+
datasets: crmDatasets,
326+
reports: [{
327+
name: 'pipeline', dataset: 'deals',
328+
runtimeFilter: { close_date: { $eq: 'last_quarter' } },
329+
blocks: [{ type: 'table', dataset: 'deals', runtimeFilter: { closed_at: 'last_year' } }],
330+
}],
331+
flows: [{
332+
name: 'sweep',
333+
nodes: [{ id: 'find', type: 'find_records', config: { objectName: 'crm_opportunity', filter: { close_date: { $ne: 'today' } } } }],
334+
}],
335+
pages: [{
336+
name: 'detail', object: 'crm_account',
337+
components: [
338+
// `record:related_list`: `properties.objectName` is the related object.
339+
{ type: 'record:related_list', properties: { objectName: 'crm_opportunity', filter: { close_date: 'last_7_days' } } },
340+
// No component binding: the page's own `object`.
341+
{ type: 'list', filter: { created_on: 'last_90_days' } },
342+
],
343+
}],
344+
dashboards: [{
345+
name: 'ops',
346+
globalFilters: [{ name: 'acct', field: 'account', type: 'select', optionsFrom: { object: 'crm_account', valueField: 'id', labelField: 'name', filter: { created_on: 'this_year' } } }],
347+
widgets: [
348+
// A relationship hop: nested condition AND dotted spelling, both resolved on `crm_account`.
349+
widget('hop', { account: { created_on: 'last_month' }, 'account.created_on': { $in: ['this_month'] } }),
350+
],
351+
}],
352+
});
353+
expect(findings.map((f) => f.path).sort()).toEqual([
354+
'dashboards[0].globalFilters[0].optionsFrom.filter.created_on',
355+
'dashboards[0].widgets[0].filter.account.created_on',
356+
'dashboards[0].widgets[0].filter.account.created_on.$in[0]',
357+
'flows[0].nodes[0].config.filter.close_date.$ne',
358+
'objects[2].fields.account.relatedListFilter.close_date.$in[0]',
359+
'objects[2].fields.note_count.summary.filter.noted_on',
360+
'objects[2].listViews[0].filter.close_date',
361+
'pages[0].components[0].properties.filter.close_date',
362+
'pages[0].components[1].filter.created_on',
363+
'reports[0].blocks[0].runtimeFilter.closed_at',
364+
'reports[0].runtimeFilter.close_date.$eq',
365+
].sort());
366+
});
367+
368+
it('binds name-keyed (map-form) objects and datasets exactly as array-form ones', () => {
369+
const findings = validatePresetComparands({
370+
objects: { crm_opportunity: { fields: { close_date: { type: 'date' } } } },
371+
datasets: { deals: { object: 'crm_opportunity', measures: [] } },
372+
dashboards: [{ name: 'sales', widgets: [widget('w', { close_date: 'last_30_days' })] }],
373+
});
374+
expect(findings.map((f) => f.path)).toEqual(['dashboards[0].widgets[0].filter.close_date']);
375+
});
376+
377+
it('stays quiet on every legitimate or unjudgeable position — the discriminating controls', () => {
378+
expect(validatePresetComparands({
379+
objects: [
380+
...crmObjects,
381+
{ name: 'crm_empty' }, // declares no field map
382+
],
383+
datasets: [
384+
...crmDatasets,
385+
{ name: 'ghost_ds', object: 'no_such_object', measures: [] },
386+
],
387+
dashboards: [{
388+
name: 'ok',
389+
widgets: [
390+
widget('picklist', {
391+
// Equality / membership against a select or text column whose
392+
// value collides with a preset name is the author's own vocabulary.
393+
stage: 'this_quarter',
394+
period: { $in: ['last_30_days'] },
395+
// A `time` column: the ruling names date / datetime only.
396+
opens_at: 'today',
397+
// A registry-injected column: its type is invisible to the graph.
398+
created_at: 'last_30_days',
399+
// A field the object does not declare: another rule's finding.
400+
close_dat: 'last_30_days',
401+
// The platform's own correct spellings in the judged positions.
402+
close_date: { $in: ['{30_days_ago}', '2026-01-15'] },
403+
closed_at: { $eq: '2026-01-15T00:00:00.000Z' },
404+
}),
405+
// Bindings that cannot resolve: an unknown dataset, a dataset on an
406+
// unknown object, an object with no field map.
407+
widget('ghost', { close_date: 'last_30_days' }, { dataset: 'no_such_dataset' }),
408+
widget('ghost2', { close_date: 'last_30_days' }, { dataset: 'ghost_ds' }),
409+
],
410+
}],
411+
views: [
412+
// A non-`object` provider names no fields on any object graph.
413+
{ name: 'api', data: { provider: 'api', object: 'crm_opportunity' }, filter: [{ field: 'close_date', operator: 'equals', value: 'last_30_days' }] },
414+
// No binding at all.
415+
{ name: 'unbound', filter: [{ field: 'close_date', operator: 'in', value: ['last_30_days'] }] },
416+
],
417+
flows: [{
418+
name: 'templated',
419+
// A templated target object is resolved at run time — skipped, not guessed.
420+
nodes: [{ id: 'n', config: { objectName: '{vars.target}', filter: { close_date: 'last_30_days' } } }],
421+
}],
422+
apps: [{ name: 'crm', filter: { close_date: 'last_30_days' } }],
423+
pages: [{ name: 'p', components: [{ type: 'list', filter: ['close_date', '=', 'last_30_days'] }] }],
424+
})).toEqual([]);
425+
});
426+
427+
// [#16106 review finding B1] A form field's `publicPicker.filter` is a static
428+
// pre-filter the public-lookup route runs on the REFERENCED object
429+
// (`picker.object`, else the field's `reference`). Binding it to the view's
430+
// own object produced a FALSE refusal — the one failure direction this arm
431+
// may never have — whenever the parent and the referenced object share a
432+
// field name with differing types.
433+
const pickerObjects = [
434+
{
435+
name: 'crm_opportunity',
436+
fields: {
437+
close_date: { type: 'date' },
438+
account: { type: 'lookup', reference: 'crm_account' },
439+
contact: { type: 'lookup', reference: 'crm_contact' },
440+
owner_note: { type: 'text' },
441+
},
442+
},
443+
// Same field NAME as the parent, a select column whose option value collides with a preset.
444+
{ name: 'crm_account', fields: { close_date: { type: 'select', options: [{ label: 'This Quarter', value: 'this_quarter' }] } } },
445+
// Same field name, genuinely a date.
446+
{ name: 'crm_contact', fields: { close_date: { type: 'date' } } },
447+
];
448+
const pickerForm = (fields: unknown[]) => ({
449+
objects: pickerObjects,
450+
views: [{
451+
name: 'lead_form', type: 'form',
452+
data: { provider: 'object', object: 'crm_opportunity' },
453+
sections: [{ fields }],
454+
}],
455+
});
456+
const pickerRule = { field: 'close_date', operator: 'equals', value: 'this_quarter' };
457+
458+
it('[B1] stays QUIET on a publicPicker filter over a referenced select column that shares its name with a parent date column', () => {
459+
// The measured false refusal: parent `close_date` is a date, the picker queries `crm_account`.
460+
expect(validatePresetComparands(pickerForm([
461+
{ field: 'account', publicPicker: { filter: [pickerRule] } },
462+
]))).toEqual([]);
463+
// The `object` override names the referenced object outright.
464+
expect(validatePresetComparands(pickerForm([
465+
{ field: 'account', publicPicker: { object: 'crm_account', filter: [pickerRule] } },
466+
]))).toEqual([]);
467+
// Unresolvable pickers stay UNJUDGED, never the parent: a field the form
468+
// object does not declare, and a field that is not a relationship (no
469+
// `reference` to follow — on the parent it would have read as a date).
470+
expect(validatePresetComparands(pickerForm([
471+
{ field: 'no_such_field', publicPicker: { filter: [pickerRule] } },
472+
{ field: 'close_date', publicPicker: { filter: [pickerRule] } },
473+
{ field: 'owner_note', publicPicker: { filter: [pickerRule] } },
474+
]))).toEqual([]);
475+
});
476+
477+
it('[B1] POSITIVE CONTROL: the same picker filter is still refused when the REFERENCED object declares the field as a date', () => {
478+
// Resolved through the field's `reference`.
479+
expect(validatePresetComparands(pickerForm([
480+
{ field: 'contact', publicPicker: { filter: [pickerRule] } },
481+
])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']);
482+
// Resolved through the `object` override (pointing a select-typed parent lookup at the date object).
483+
expect(validatePresetComparands(pickerForm([
484+
{ field: 'account', publicPicker: { object: 'crm_contact', filter: [pickerRule] } },
485+
])).map((f) => f.path)).toEqual(['views[0].sections[0].fields[0].publicPicker.filter[0].value']);
486+
});
487+
488+
it('keeps arm 1 field-agnostic: an ordering preset still fires with NO objects in the stack, and on a text column', () => {
489+
const findings = validatePresetComparands({
490+
objects: crmObjects,
491+
datasets: crmDatasets,
492+
dashboards: [{ name: 'd', widgets: [widget('w', { period: { $gte: 'last_30_days' }, close_date: 'last_30_days' })] }],
493+
views: [{ name: 'v', filter: [{ field: 'anything', operator: 'after', value: 'last_7_days' }] }],
494+
});
495+
expect(findings.map((f) => f.path).sort()).toEqual([
496+
'dashboards[0].widgets[0].filter.close_date',
497+
'dashboards[0].widgets[0].filter.period.$gte',
498+
'views[0].filter[0].value',
499+
]);
500+
});
501+
});

0 commit comments

Comments
 (0)