diff --git a/.changeset/lint-preset-comparand-field-typed-arm.md b/.changeset/lint-preset-comparand-field-typed-arm.md new file mode 100644 index 0000000000..3964edc750 --- /dev/null +++ b/.changeset/lint-preset-comparand-field-typed-arm.md @@ -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. diff --git a/.changeset/spec-preset-comparand-message-tsdoc.md b/.changeset/spec-preset-comparand-message-tsdoc.md new file mode 100644 index 0000000000..8b543ce70f --- /dev/null +++ b/.changeset/spec-preset-comparand-message-tsdoc.md @@ -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. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 126e471db6..4580834955 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -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', diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index 9e045fb0e4..e42b2ceca3 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -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 = {}) => ({ + 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', + ]); + }); +}); diff --git a/packages/lint/src/validate-preset-comparands.ts b/packages/lint/src/validate-preset-comparands.ts index cad2360674..23a8afdb33 100644 --- a/packages/lint/src/validate-preset-comparands.ts +++ b/packages/lint/src/validate-preset-comparands.ts @@ -10,10 +10,11 @@ import { import { normalizeFilterOperator } from '@objectstack/spec/ui'; import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; +import { indexObjectGraph, recordsOf, resolveFieldPath, type ObjectGraph } from './object-graph.js'; /** - * Build-time refusal of a bare dashboard date-range PRESET name in an ordering - * comparand (#8793 — the ruled C half of #8690). + * Build-time refusal of a bare dashboard date-range PRESET name authored as a + * filter comparand — two arms, one wording. * * `last_7_days` / `last_30_days` / `last_90_days` and their ten calendar * siblings are REAL declared names (`DATE_RANGE_PRESETS`, @@ -39,10 +40,10 @@ import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; * (`['created_at', '>=', …]`) — and reports with the located `where` / `path` * the CLI commands render. * - * ## The boundary — ordering positions only, in all three shapes + * ## Arm 1 — ordering positions, FIELD-AGNOSTIC (#8793) * - * This rule is field-agnostic, so the judgement rides on POSITION, exactly as - * the schema door's #8793 note lays out at length: + * The judgement rides on POSITION alone, exactly as the schema door's #8793 + * note lays out at length: * * - **Judged:** `$gt` / `$gte` / `$lt` / `$lte` comparands and `$between` * endpoints (Mongo shape); `>` / `>=` / `<` / `<=` / `between` triples and @@ -50,17 +51,105 @@ import { walkAuthoredFilters, type FilterSurface } from './filter-walk.js'; * `greater_than` / `less_than` / `greater_than_or_equal` / * `less_than_or_equal` / `before` / `after` / `between` view filter rules * and every alias `normalizeFilterOperator` folds onto them. An ORDERED - * comparison against a declared preset name has no legitimate reading. - * - **Not judged:** equality and membership (`=`, `equals`, `$eq`, `$ne`, - * `$in`, `$nin`, …). A select/picklist column legitimately stores values - * that collide with preset names (`GlobalFilterSchema`'s own pins protect - * `type: 'select', defaultValue: 'this_quarter'`), and on a declared - * temporal field the engine door still refuses these at query time with the - * field's type in hand. + * comparison against a declared preset name has no legitimate reading on + * ANY column, so no field type is needed to refuse it. * - **Only the 13 declared names.** A near-miss (`last_60_days`) is not this * rule's business — on a temporal field the engine's field-typed door * catches it; judging undeclared strings here would be a guessed superset. * + * ## Arm 2 — equality and membership positions, FIELD-TYPED (#16106) + * + * Equality (`{ period: 'this_quarter' }`, `$eq`, `$ne`) and membership (`$in` + * / `$nin`) CANNOT be judged blind: a select/picklist column legitimately + * stores values that collide with preset names (`GlobalFilterSchema`'s own + * pins protect `type: 'select', defaultValue: 'this_quarter'`), and equality + * against such a stored value is a working filter. That is why the schema + * door — field-agnostic by construction — keeps its ordering-only boundary, + * and why this arm exists HERE, where the stack's object metadata is in hand: + * maintainer-ruled 2026-09-06 (#16106, comment 5557019138, adopting + * recommendation 1′): *at a layer that holds the object metadata, a declared + * `date` / `datetime` field refuses one of the 13 declared preset names in + * EVERY comparand position — bare (implicit equality), `$eq` / `$ne`, `$in` / + * `$nin` and their view-rule and triple spellings, alongside the ordering + * positions already judged.* Same message, same prescription (the #5240 + * convention): the window the preset already means is exactly what an author + * writing `close_date == 'last_30_days'` intended. + * + * Measured first (ruling item 3), as real queries on a declared `date` field + * (`close_date`) and a declared `datetime` sibling, on two real drivers — + * `@objectstack/driver-memory` and `@objectstack/driver-sqlite-wasm` — 30 rows + * seeded / 20 inside a 30-day window; both drivers answered identically: + * + * ``` + * close_date: 'last_30_days' REFUSED INVALID_FILTER / 400 (bare) + * close_date: { $eq: 'last_30_days' } REFUSED INVALID_FILTER / 400 + * close_date: { $in: ['last_30_days'] } REFUSED INVALID_FILTER / 400 + * close_date: { $ne / $nin … } REFUSED INVALID_FILTER / 400 + * close_date: { $gte: '{30_days_ago}' } 200 count=20 <- positive control + * close_date: '2026-09-03' 200 count=1 <- equality works on a date + * stage: 'this_quarter' (select column) 200 count=10 <- the picklist case, alive + * ``` + * + * So at QUERY time the engine door already refuses every residue position on a + * declared temporal field — the residue was purely an AUTHORING-time gap: + * `objectstack lint` passed and `defineStack` accepted a filter the runtime + * then refused with a 400 on first render. This arm closes that gap where the + * AI author's correction loop can see it. + * + * ### Which object a filter is judged against + * + * `walkAuthoredFilters` finds the subtrees; this arm re-walks each subtree's + * config path back through its ancestors and binds the filter to the NEAREST + * ancestor that declares an object, in the spellings the carriers actually + * use (each one the same read a sibling rule already makes at that position): + * + * - a literal `object` / `objectName` — a dataset, a summary field's child + * object, a global filter's `optionsFrom`, a page data source, a + * `record:related_list`'s `properties`, a time-relative trigger; + * - a `dataset` name, resolved to that dataset's `object` — dashboard widgets + * (`validate-widget-bindings`, #14148), reports and report blocks; + * - `data: { provider: 'object', object }` — standalone views and list views + * (`validate-list-view-field-refs`, #14107); any OTHER provider names no + * object, so the position is unjudged; + * - `config.objectName` / `config.object` — flow CRUD nodes + * (`validate-flow-node-writes`), a templated `{…}` value skipped; + * - `dataSource.object`, then `properties.object` / `properties.objectName` + * — page components (`validate-page-field-bindings`); + * - `publicPicker.object`, else the enclosing form field's `reference` + * resolved on the view's object, else NOTHING — a form field's public-lookup + * picker (`FormFieldPublicPickerSchema`) queries the REFERENCED object, so + * its `filter` must never fall through to the parent form object (#16106 + * review finding B1: that fall-through was a false refusal wherever the two + * objects share a field name with differing types); + * - and, under `objects`, the object itself — its list views, tabs and + * `relatedListFilter` (the filter runs over the CHILD rows, i.e. the object + * that owns the field). + * + * The field key (a bare name, or a dotted relationship path) is then resolved + * through `resolveFieldPath` (`object-graph.ts`), and the comparand is judged + * only when the LEAF resolves to an author-declared field of type `date` or + * `datetime` — the two types the ruling names. + * + * ### What this arm deliberately does NOT judge + * + * Every one of these is a MISSED CATCH (the engine door still refuses it at + * query time, field type in hand), never a false build error: + * + * - a position no ancestor binds (an app-level filter, a dashboard-level + * filter outside a widget), a dataset or object the stack does not declare, + * an object with no readable field map, a view on a non-`object` provider; + * - a leaf that resolves only as a registry-INJECTED column (`created_at`, + * `updated_at`, …): the object graph carries no type for those — their type + * is registry-owned and invisible here (`FieldPathVerdict`'s own contract); + * - a `time` field: the ruling names `date` / `datetime`, and a wall-clock + * column has no preset-shaped authoring slip worth a rule of its own; + * - a field the object does not declare at all (a typo) — that is the + * `*-filter-field-unknown` rules' finding, not a second one here. + * + * The implicit-equality position is reported under the operator it LOWERS to, + * `$eq`, at the path of the field itself (`…filter.close_date`, no operator + * segment), so the located path still says exactly what was authored. + * * Both vocabularies' `{placeholder}` spellings never collide with a preset * name (a preset carries no braces), so this rule and `validate-filter-tokens` * cannot double-report one value. @@ -99,7 +188,7 @@ const PRESET_COMPARAND_SURFACES: readonly FilterSurface[] = [ { key: 'flows', kind: 'flow' }, ]; -/** Mongo-shape ordering operators whose scalar comparand is judged. */ +/** Mongo-shape ordering operators whose scalar comparand is judged field-agnostically (arm 1). */ const ORDERING_DOLLAR_OPS: ReadonlySet = new Set(['$gt', '$gte', '$lt', '$lte']); /** @@ -119,6 +208,30 @@ const ORDERING_RULE_OPS: ReadonlySet = new Set([ 'before', 'after', ]); +/** + * [#16106] Mongo-shape EQUALITY operators whose scalar comparand is judged + * only with the field type in hand (arm 2). The implicit-equality position + * (`{ field: value }`) is the third member, spelled by its absence. + */ +const EQUALITY_DOLLAR_OPS: ReadonlySet = new Set(['$eq', '$ne']); + +/** [#16106] Mongo-shape MEMBERSHIP operators — every member of the list is a comparand. */ +const MEMBERSHIP_DOLLAR_OPS: ReadonlySet = new Set(['$in', '$nin']); + +/** [#16106] Canonical infix equality / membership, post `canonicalAstOperator` fold. */ +const EQUALITY_INFIX_OPS: ReadonlySet = new Set(['=', '!=']); +const MEMBERSHIP_INFIX_OPS: ReadonlySet = new Set(['in', 'nin']); + +/** [#16106] Canonical view-rule equality / membership, post `normalizeFilterOperator` fold. */ +const EQUALITY_RULE_OPS: ReadonlySet = new Set(['equals', 'not_equals']); +const MEMBERSHIP_RULE_OPS: ReadonlySet = new Set(['in', 'not_in']); + +/** + * [#16106] The declared field types arm 2 judges — exactly the two the ruling + * names. `time` is deliberately absent (see the module note). + */ +const FIELD_TYPED_TEMPORAL_TYPES: ReadonlySet = new Set(['date', 'datetime']); + /** Recursion guard — an authored filter is a bounded document, not a general graph. */ const MAX_DEPTH = 32; @@ -126,6 +239,20 @@ function isPlainObject(v: unknown): v is AnyRec { return !!v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Date); } +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * An object name written as a literal. A templated `{…}` value (a flow node + * resolving its target from variables at run time) is skipped, not guessed — + * the same read `validate-flow-node-writes.ts` makes. + */ +function literalObjectName(v: unknown): string | undefined { + const s = strName(v); + return s && !s.includes('{') ? s : undefined; +} + function finding( where: string, path: string, @@ -145,13 +272,195 @@ function finding( }; } -/** Judge one Mongo-style condition NODE's own field entries. */ +// ── Arm 2's field-type oracle ───────────────────────────────────────────────── + +/** + * "Is this field key, resolved against the filter's bound object, an + * author-declared `date` / `datetime` field?" — the one question arm 2 asks. + * A key it cannot answer (no bound object, an unresolvable path, an injected + * leaf, any other type) answers `false`, so the position stays unjudged. + */ +type TemporalFieldOracle = (field: string) => boolean; + +const UNBOUND: TemporalFieldOracle = () => false; + +function temporalFieldOracle(graph: ObjectGraph, object: string | undefined): TemporalFieldOracle { + if (!object) return UNBOUND; + return (field) => { + const verdict = resolveFieldPath(graph, object, field); + if (!verdict || verdict.kind !== 'ok' || verdict.injected) return false; + const type = verdict.meta?.type; + return typeof type === 'string' && FIELD_TYPED_TEMPORAL_TYPES.has(type); + }; +} + +/** + * Split a walk path (`dashboards[0].widgets[2].filter`) into its segments. + * `null` for a spelling this reader does not understand — a key carrying a + * `.` or `[` — which leaves the position unbound rather than mis-bound. + */ +function pathSegments(path: string): (string | number)[] | null { + const out: (string | number)[] = []; + for (const piece of path.split('.')) { + const m = /^([^[\]]+)((?:\[\d+\])*)$/.exec(piece); + if (!m) return null; + out.push(m[1]); + for (const index of m[2].match(/\d+/g) ?? []) out.push(Number(index)); + } + return out; +} + +/** One record on the way to a filter, with the property NAME it was reached under. */ +interface Ancestor { + /** The nearest enclosing property name — `sections[0]` is reached under `sections`. */ + key: string; + node: AnyRec; +} + +/** + * The records on the way from the stack collection item down to (but not + * including) the filter subtree at `path`, outermost first — read back through + * the SAME `recordsOf` coercion `walkAuthoredFilters` applied to the + * collection, so a map-form collection's injected `name` is visible here too. + */ +function ancestorsOf(stack: AnyRec, path: string): { collection: string; chain: Ancestor[] } | null { + const segments = pathSegments(path); + if (!segments || segments.length < 3) return null; + const [collection, index, ...rest] = segments; + if (typeof collection !== 'string' || typeof index !== 'number') return null; + const item = recordsOf(stack[collection])[index]; + if (!item) return null; + const chain: Ancestor[] = [{ key: collection, node: item }]; + let node: unknown = item; + let key = collection; + // The last segment is the filter key itself; everything before it is an ancestor. + for (const segment of rest.slice(0, -1)) { + if (typeof segment === 'string') key = segment; + node = Array.isArray(node) + ? node[segment as number] + : isPlainObject(node) ? node[segment as string] : undefined; + if (isPlainObject(node)) chain.push({ key, node }); + else if (!Array.isArray(node)) break; + } + return { collection, chain }; +} + +/** + * The key under which a form field carries its public-lookup picker + * (`FormFieldPublicPickerSchema`, `ui/view.zod.ts`). Its `filter` is a static + * pre-filter the public-lookup route runs on the REFERENCED object — + * `picker.object` when written, else the field definition's `reference` — + * never on the form's own object. + */ +const PUBLIC_PICKER_KEY = 'publicPicker'; + +/** + * Bind one authored filter to the object its conditions address — the NEAREST + * ancestor that declares one, in the carriers' own spellings (module note, + * "Which object a filter is judged against"). A reader that CLAIMS the + * position but cannot resolve it (a `dataset` the stack does not declare, a + * non-`object` view provider) ends the search: the position is unknowable, + * and an outer ancestor's object would be the wrong one. + */ +function boundObjectOf( + stack: AnyRec, + path: string, + datasets: ReadonlyMap, + graph: ObjectGraph, +): string | undefined { + const located = ancestorsOf(stack, path); + if (!located) return undefined; + return bindAncestors(located.collection, located.chain, located.chain.length - 1, datasets, graph); +} + +/** The reader loop behind {@link boundObjectOf}, from ancestor `from` outward. */ +function bindAncestors( + collection: string, + chain: readonly Ancestor[], + from: number, + datasets: ReadonlyMap, + graph: ObjectGraph, +): string | undefined { + for (let i = from; i >= 0; i--) { + const { key, node: r } = chain[i]; + + // [#16106 B1] A form field's `publicPicker` is a CLAIMING reader: the + // picker queries the referenced object, so the position binds to + // `picker.object`, else to the `reference` of the enclosing form field + // resolved on the view's own object — and to NOTHING otherwise. Falling + // through to the view's `data.object` (the parent form object) bound the + // filter to the wrong object and produced a FALSE refusal wherever the + // parent and the referenced object share a field name with differing + // types (a `date` on the parent, a `select` whose option value is a + // preset name on the referenced object). + if (key === PUBLIC_PICKER_KEY) { + const override = literalObjectName(r.object); + if (override) return override; + const formField = strName(chain[i - 1]?.node.field); + if (!formField) return undefined; + const formObject = bindAncestors(collection, chain, i - 2, datasets, graph); + if (!formObject) return undefined; + const verdict = resolveFieldPath(graph, formObject, formField); + return verdict?.kind === 'ok' ? strName(verdict.meta?.reference) : undefined; + } + + const direct = literalObjectName(r.object) ?? literalObjectName(r.objectName); + if (direct) return direct; + + const datasetName = strName(r.dataset); + if (datasetName) { + const dataset = datasets.get(datasetName); + return dataset ? literalObjectName(dataset.object) : undefined; + } + + if (isPlainObject(r.data)) { + const provider = r.data.provider; + if (provider !== undefined && provider !== 'object') return undefined; + const viaData = literalObjectName(r.data.object); + if (viaData) return viaData; + } + + if (isPlainObject(r.config)) { + const viaConfig = literalObjectName(r.config.objectName) ?? literalObjectName(r.config.object); + if (viaConfig) return viaConfig; + } + + if (isPlainObject(r.dataSource)) { + const viaSource = literalObjectName(r.dataSource.object); + if (viaSource) return viaSource; + } + + if (isPlainObject(r.properties)) { + const viaProps = literalObjectName(r.properties.object) ?? literalObjectName(r.properties.objectName); + if (viaProps) return viaProps; + } + } + + // Under `objects`, the collection item IS the object every filter on it + // addresses — read last, so a nearer declaration (a summary field's child + // object) wins. + if (collection === 'objects') return strName(chain[0].node.name); + return undefined; +} + +// ── The three authored shapes ───────────────────────────────────────────────── + +/** + * Judge one Mongo-style condition NODE's own field entries. + * + * `prefix` carries the relationship path accumulated by descending nested + * condition objects (`{ account: { created_at: … } }` is judged as + * `account.created_at`), so arm 2 resolves the leaf on the object the hop + * lands on — the same accumulation `walkFilterFieldKeys` performs. + */ function judgeConditionNode( node: AnyRec, path: string, where: string, out: PresetComparandFinding[], depth: number, + isTemporal: TemporalFieldOracle, + prefix: string, ): void { if (depth > MAX_DEPTH) return; for (const [key, value] of Object.entries(node)) { @@ -159,24 +468,33 @@ function judgeConditionNode( if (key === '$and' || key === '$or') { if (Array.isArray(value)) { value.forEach((arm, i) => { - if (isPlainObject(arm)) judgeConditionNode(arm, `${here}[${i}]`, where, out, depth + 1); + if (isPlainObject(arm)) judgeConditionNode(arm, `${here}[${i}]`, where, out, depth + 1, isTemporal, prefix); }); } continue; } if (key === '$not') { - if (isPlainObject(value)) judgeConditionNode(value, here, where, out, depth + 1); + if (isPlainObject(value)) judgeConditionNode(value, here, where, out, depth + 1, isTemporal, prefix); continue; } if (key.startsWith('$')) continue; // unrecognised combinator — skipped, not descended - if (!isPlainObject(value)) continue; // implicit equality — deliberately not judged + const field = prefix ? `${prefix}.${key}` : key; + if (!isPlainObject(value)) { + // Implicit equality — arm 2, field type in hand (#16106). Reported under + // the operator it lowers to, at the path of the field itself. + if (isDateRangePresetName(value) && isTemporal(field)) { + out.push(finding(where, here, value, '$eq')); + } + continue; + } const hasOps = Object.keys(value).some((k) => k.startsWith('$')); if (!hasOps) { // Nested relation / deep equality — descend. - judgeConditionNode(value, here, where, out, depth + 1); + judgeConditionNode(value, here, where, out, depth + 1, isTemporal, field); continue; } for (const [op, comparand] of Object.entries(value)) { + // Arm 1 — ordering, field-agnostic. if (ORDERING_DOLLAR_OPS.has(op) && isDateRangePresetName(comparand)) { out.push(finding(where, `${here}.${op}`, comparand, op)); continue; @@ -187,6 +505,19 @@ function judgeConditionNode( out.push(finding(where, `${here}.${op}[${i}]`, endpoint, op)); } }); + continue; + } + // Arm 2 — equality / membership, field-typed (#16106). + if (EQUALITY_DOLLAR_OPS.has(op) && isDateRangePresetName(comparand) && isTemporal(field)) { + out.push(finding(where, `${here}.${op}`, comparand, op)); + continue; + } + if (MEMBERSHIP_DOLLAR_OPS.has(op) && Array.isArray(comparand) && isTemporal(field)) { + comparand.forEach((member, i) => { + if (isDateRangePresetName(member)) { + out.push(finding(where, `${here}.${op}[${i}]`, member, op)); + } + }); } } } @@ -198,10 +529,12 @@ function judgeFilterRule( path: string, where: string, out: PresetComparandFinding[], + isTemporal: TemporalFieldOracle, ): void { const operator = normalizeFilterOperator(rule.operator); if (typeof operator !== 'string') return; const value = rule.value; + // Arm 1 — ordering, field-agnostic. if (ORDERING_RULE_OPS.has(operator) && isDateRangePresetName(value)) { out.push(finding(where, `${path}.value`, value, operator)); return; @@ -212,6 +545,21 @@ function judgeFilterRule( out.push(finding(where, `${path}.value[${i}]`, endpoint, operator)); } }); + return; + } + // Arm 2 — equality / membership, field-typed (#16106). + const field = strName(rule.field); + if (!field || !isTemporal(field)) return; + if (EQUALITY_RULE_OPS.has(operator) && isDateRangePresetName(value)) { + out.push(finding(where, `${path}.value`, value, operator)); + return; + } + if (MEMBERSHIP_RULE_OPS.has(operator) && Array.isArray(value)) { + value.forEach((member, i) => { + if (isDateRangePresetName(member)) { + out.push(finding(where, `${path}.value[${i}]`, member, operator)); + } + }); } } @@ -221,11 +569,13 @@ function judgeTriple( path: string, where: string, out: PresetComparandFinding[], + isTemporal: TemporalFieldOracle, ): void { const op = triple[1]; if (typeof op !== 'string') return; const canonical = canonicalAstOperator(op); const value = triple[2]; + // Arm 1 — ordering, field-agnostic. if (ORDERING_INFIX_OPS.has(canonical) && isDateRangePresetName(value)) { out.push(finding(where, `${path}[2]`, value, op)); return; @@ -236,6 +586,21 @@ function judgeTriple( out.push(finding(where, `${path}[2][${i}]`, endpoint, op)); } }); + return; + } + // Arm 2 — equality / membership, field-typed (#16106). + const field = strName(triple[0]); + if (!field || !isTemporal(field)) return; + if (EQUALITY_INFIX_OPS.has(canonical) && isDateRangePresetName(value)) { + out.push(finding(where, `${path}[2]`, value, op)); + return; + } + if (MEMBERSHIP_INFIX_OPS.has(canonical) && Array.isArray(value)) { + value.forEach((member, i) => { + if (isDateRangePresetName(member)) { + out.push(finding(where, `${path}[2][${i}]`, member, op)); + } + }); } } @@ -255,6 +620,7 @@ function judgeFilterValue( where: string, out: PresetComparandFinding[], depth: number, + isTemporal: TemporalFieldOracle, ): void { if (depth > MAX_DEPTH) return; @@ -266,13 +632,13 @@ function judgeFilterValue( && !['and', 'or'].includes(node[0].toLowerCase()) && VALID_AST_OPERATORS.has(node[1].toLowerCase()) ) { - judgeTriple(node, path, where, out); + judgeTriple(node, path, where, out, isTemporal); return; } // Group ['and'|'or', ...members] or bare list — recurse the members. node.forEach((member, i) => { if (typeof member === 'string') return; // the leading keyword - judgeFilterValue(member, `${path}[${i}]`, where, out, depth + 1); + judgeFilterValue(member, `${path}[${i}]`, where, out, depth + 1, isTemporal); }); return; } @@ -281,19 +647,20 @@ function judgeFilterValue( // View filter rule: { field, operator[, value] }. if (typeof node.field === 'string' && typeof node.operator === 'string') { - judgeFilterRule(node, path, where, out); + judgeFilterRule(node, path, where, out, isTemporal); return; } - judgeConditionNode(node, path, where, out, depth); + judgeConditionNode(node, path, where, out, depth, isTemporal, ''); } /** * Validate every authored filter across a stack for bare preset comparands. * - * Pure `(stack) => Finding[]`; no I/O. Needs no resolution context — the - * judgement is on the filter literal alone — which is what qualifies it for - * the runtime publish gate's per-write snapshot. + * Pure `(stack) => Finding[]`; no I/O. Arm 1 judges the filter literal alone; + * arm 2 additionally reads the stack's own `objects` (and `datasets`, to bind + * a widget or report) — both collections the runtime publish gate's per-write + * snapshot carries — and stays silent wherever they are absent. */ export function validatePresetComparands( stack: Record | undefined | null, @@ -301,8 +668,16 @@ export function validatePresetComparands( if (!stack || typeof stack !== 'object') return []; const out: PresetComparandFinding[] = []; + const graph = indexObjectGraph(stack); + const datasets = new Map(); + for (const dataset of recordsOf(stack.datasets)) { + const name = strName(dataset.name); + if (name) datasets.set(name, dataset); + } + walkAuthoredFilters(stack, PRESET_COMPARAND_SURFACES, ({ value, path, where }) => { - judgeFilterValue(value, path, where, out, 0); + const isTemporal = temporalFieldOracle(graph, boundObjectOf(stack, path, datasets, graph)); + judgeFilterValue(value, path, where, out, 0, isTemporal); }); return out; diff --git a/packages/spec/src/data/date-range-presets.ts b/packages/spec/src/data/date-range-presets.ts index 3126dbf6f7..8c18caad0b 100644 --- a/packages/spec/src/data/date-range-presets.ts +++ b/packages/spec/src/data/date-range-presets.ts @@ -99,10 +99,14 @@ export const DATE_RANGE_PRESET_MACRO_WINDOWS: Readonly< /** * The one refusal wording for "a declared preset name authored as a bare - * ordering comparand", shared by the two moments it can be reported — the - * schema door in `data/filter.zod.ts` and `@objectstack/lint`'s - * `filter-preset-comparand` rule — so one condition keeps one wording - * (the #5240 convention). + * filter comparand", shared by the moments it can be reported — 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 (the same ordering + * positions, plus — with the field type in hand — EVERY comparand position on + * a declared `date` / `datetime` field: bare, `$eq` / `$ne`, `$in` / `$nin` + * and their view-rule and triple spellings; #16106, maintainer-ruled 1′) — so + * one condition keeps one wording (the #5240 convention). * * Why this is refused at all (#8690, C half, maintainer-ruled 2026-08-15): * `last_30_days` and its siblings are REAL declared names — but only for the