Skip to content

Commit a777944

Browse files
feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793) (#8935)
* feat(spec,lint): refuse bare date-range preset names in ordering filter comparands (#8793) The C half of #8690 (maintainer ruling 5299879288): the declared dashboard date-range preset vocabulary (last_7_days / last_30_days / last_90_days and their ten calendar siblings) is refused at publish time when authored as a bare ordering comparand, where no layer of the platform can interpret it. - data/date-range-presets.ts: vocabulary re-homed from ui/dashboard.zod.ts (single source #4614 kept; ui re-exports) + macro-window prescriptions - data/filter.zod.ts: FilterConditionSchema refuses presets under $gt/$gte/$lt/$lte and $between endpoints, ordering positions only - lint: new filter-preset-comparand rule, all three authored filter shapes, CLI + runtime-publish surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt * feat(spec): ADR-0087 entry, changeset, runtime-gate pin updates (#8793) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt * fix(lint): give the new rule test its .js import extension — clears the +2 TEST_DEBT drift (#8793) Same class as #8808's TS2550: invisible to 'pnpm --filter @objectstack/lint typecheck' because tsconfig excludes **/*.test.ts; only the TEST_DEBT re-measure compiles the file. The two TS7006s were downstream of the one unresolved import (TS2835). Ledger not raised; re-measured 19 vs recorded 20 on the fully built workspace closure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt * chore(spec): regenerate os-regen artifacts after merging main's registry sibling (#8793) os-regen relay for #8932's retired-def:18 landing: merge committed first, then gen:migration-registry / api-surface / export-origins / spec-changes / upgrade-guide / openapi re-run on the merged tree. Both sides asserted surviving: this branch's filter-preset-ordering-comparand-refused entry AND #8932's identity-api-key-schema-retired entry + its identity.zod.ts deletion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fgvh1iEJfxetei7aNVdtJt --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c86fe3 commit a777944

18 files changed

Lines changed: 1205 additions & 27 deletions
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(spec,lint): refuse a bare date-range preset name in an ordering filter comparand at publish time (#8793 — the ruled C half of #8690)
7+
8+
**BREAKING** accept-set narrowing on a published authoring surface, landing
9+
after the v17.0.0 cut (the lockstep launch-window convention ships it as
10+
`minor`; the migration prescription is registered under protocol major 18).
11+
12+
`last_7_days` / `last_30_days` / `last_90_days` and their ten calendar
13+
siblings are real, declared preset names — for the dashboard date-filter
14+
positions, where the console lowers them to `{date-macro}` bounds before any
15+
query is sent. Authored as a bare filter comparand nothing resolves them:
16+
measured on #8690, `$gte "last_30_days"` returned HTTP 200 with 0 of 51 rows
17+
where `$gte "{30_days_ago}"` returned the 38 in-window. The engine now
18+
refuses the bare name on a declared temporal field at query time
19+
(`INVALID_FILTER` / 400, PR #8808 — the B half); this change is the
20+
authoring-time half the same ruling shipped alongside it.
21+
22+
**What is refused — ordering positions only, in all three authored filter
23+
shapes:** a `$gt` / `$gte` / `$lt` / `$lte` comparand or `$between` endpoint
24+
on every carrier of `FilterConditionSchema` (dashboard widget filter, dataset
25+
filter, report `runtimeFilter`, page/component filter, rollup filter), a
26+
`greater_than` / `less_than` / `before` / `after` / `between` view filter
27+
rule value, and an ordering `[field, op, value]` filter triple (the latter
28+
two via `@objectstack/lint`'s new gating rule `filter-preset-comparand`,
29+
which also runs at the runtime publish gate for `dashboard` / `view` /
30+
`object` / `page` / `flow` writes). The refusal names the offending value,
31+
the position, and the exact `{date-macro}` window that works.
32+
33+
**What stays accepted:** the preset names in the dashboard date-filter
34+
positions (`dateRange.defaultRange`, a date global filter's `defaultValue`) —
35+
the only positions any layer ever resolved them; equality and membership
36+
comparands (`{ period: 'this_quarter' }`, `$in: [...]`) — a select/picklist
37+
column legitimately stores colliding values, and the engine's field-typed
38+
door already covers the temporal case; undeclared strings
39+
(`'not-a-date-at-all'`) — the field-typed engine door owns those; and the
40+
empty-string cell, which stays its own card by ruling.
41+
42+
## FROM → TO
43+
44+
```ts
45+
// before — parsed green, returned a silent zero (or 400 at query time since #8808)
46+
filter: { closed_at: { $gte: 'last_30_days' } }
47+
48+
// after — rejected naming the window; write the date-macro spelling
49+
filter: { closed_at: { $gte: '{30_days_ago}' } }
50+
// calendar presets prescribe their pair:
51+
filter: { closed_at: { $between: ['{week_start}', '{week_end}'] } }
52+
```
53+
54+
`DATE_RANGE_PRESETS` moved to `@objectstack/spec/data`
55+
(`data/date-range-presets.ts`) with `ui` re-exporting it, so both import
56+
paths keep working; `DATE_RANGE_PRESET_MACRO_WINDOWS` (the per-preset macro
57+
window table the refusals quote) and `isDateRangePresetName` are new exports.
58+
59+
<!-- adr-0087: registered filter-preset-ordering-comparand-refused -->

‎packages/lint/src/authoring-rule-wiring.test.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,13 @@ describe('authoring-rule registry wiring (#4409)', () => {
495495
// for `view`. Flipping the family back to CLI-only is a legal edit that
496496
// must go through this line rather than around it.
497497
expect(wired.map((e) => e.name)).toEqual(FAMILY);
498-
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual(FAMILY);
498+
// `view` writes also dispatch `validatePresetComparands` (#8793) — a
499+
// different judgement (filter comparands, not predicates), registered
500+
// ahead of the family, so it is named here rather than folded into it.
501+
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
502+
'validatePresetComparands',
503+
...FAMILY,
504+
]);
499505
});
500506
});
501507

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
105105
import { validateWidgetBindings } from './validate-widget-bindings.js';
106106
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
107107
import { validateFilterTokens } from './validate-filter-tokens.js';
108+
import { validatePresetComparands } from './validate-preset-comparands.js';
108109
import { validateEmptyCombinators } from './validate-empty-combinators.js';
109110
import { validateReferenceIntegrity } from './reference-integrity-suite.js';
110111
import { validateComponentProps } from './validate-component-props.js';
@@ -542,6 +543,28 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
542543
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
543544
run: (stack) => validateFilterTokens(stack),
544545
},
546+
// #8793 (the ruled C half of #8690) — a declared dashboard date-range preset
547+
// name (`last_30_days`, …) authored as a bare ORDERING comparand resolves in
548+
// no layer: the engine refuses it on a declared temporal field at query time
549+
// (INVALID_FILTER / 400, PR #8808), and anywhere else it compares as a
550+
// literal string. This is the authoring-time refusal the ruling shipped
551+
// alongside the engine door, judging the filter literal in isolation —
552+
// ordering positions only, all three authored filter shapes. Like
553+
// `validateEmptyCombinators` it needs NO resolution context, so
554+
// RUNTIME_NEEDS_FULL_SNAPSHOT does not apply and the runtime gate runs it
555+
// for every filter-carrying type the gate already maps: the write path is
556+
// the one door an AI author uses, and dashboards/views are where the preset
557+
// vocabulary is near enough to reach for.
558+
{
559+
name: 'validatePresetComparands',
560+
tier: 'gating',
561+
input: 'parsed',
562+
commands: ALL,
563+
source: 'packages/lint/src/validate-preset-comparands.ts',
564+
surfaces: CLI_AND_RUNTIME,
565+
runtimeTypes: ['dashboard', 'view', 'object', 'page', 'flow'],
566+
run: (stack) => validatePresetComparands(stack),
567+
},
545568
// #5330 — the LITERAL empty combinators (`$and: []`, `$or: []`, `$not: {}`,
546569
// `{}`). #5322 ruled their RUNTIME meaning to be the boolean identity, and
547570
// this rule does not touch it: it refuses the literal SPELLINGS at authoring

‎packages/lint/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,8 @@ export type {
304304

305305
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
306306
export type { FilterTokenFinding, FilterTokenSeverity } from './validate-filter-tokens.js';
307+
export { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js';
308+
export type { PresetComparandFinding, PresetComparandSeverity } from './validate-preset-comparands.js';
307309

308310
// #5330 — the same subtree, judged for SHAPE rather than for its strings. The
309311
// runtime meaning of an empty combinator is settled (#5322: boolean identity,

‎packages/lint/src/runtime-gate.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,10 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
322322
expect(runtimeGatedTypes()).toContain('view');
323323
expect(stackKeyForType('view')).toBe('views');
324324
expect(runtimeAuthoringRulesFor('view').map((r) => r.name)).toEqual([
325+
// #8793 — filter comparands, not predicates; registered ahead of the
326+
// family and dispatched for `view` because list-view filter rules are
327+
// one of the three shapes it judges.
328+
'validatePresetComparands',
325329
'validateVisibilityPredicates',
326330
'validatePredicatePathRefs',
327331
]);
@@ -480,6 +484,7 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
480484
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
481485
// "clean" and "nothing ran" must stay distinguishable.
482486
expect(result.rulesRun).toEqual([
487+
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
483488
'validateVisibilityPredicates',
484489
'validatePredicatePathRefs',
485490
]);
@@ -572,6 +577,7 @@ describe('the publish gate judges a schema-bound form at its own layer (#7815)',
572577
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
573578
// "clean" and "nothing ran" must stay distinguishable.
574579
expect(result.rulesRun).toEqual([
580+
'validatePresetComparands', // #8793 — dispatched for `view`, clean here
575581
'validateVisibilityPredicates',
576582
'validatePredicatePathRefs',
577583
]);
@@ -711,6 +717,9 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
711717
expect(stackKeyForType('dashboard')).toBe('dashboards');
712718
expect(runtimeAuthoringRulesFor('dashboard').map((r) => r.name)).toEqual([
713719
'validateWidgetBindings',
720+
// #8793 — bare preset names in widget-filter ordering comparands are
721+
// judged at the same door (no resolution context needed).
722+
'validatePresetComparands',
714723
]);
715724
});
716725

@@ -749,7 +758,7 @@ describe('dashboard widget dataset bindings at the runtime publish gate (#7529)'
749758
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
750759
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
751760
// "clean" and "nothing ran" must stay distinguishable.
752-
expect(result.rulesRun).toEqual(['validateWidgetBindings']);
761+
expect(result.rulesRun).toEqual(['validateWidgetBindings', 'validatePresetComparands']);
753762
});
754763

755764
it('the `datasets` snapshot key is LOAD-BEARING: without it the same board is refused', () => {
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { validatePresetComparands, FILTER_PRESET_COMPARAND } from './validate-preset-comparands.js';
5+
6+
describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => {
7+
it('returns nothing for an empty / absent stack', () => {
8+
expect(validatePresetComparands(undefined)).toEqual([]);
9+
expect(validatePresetComparands(null)).toEqual([]);
10+
expect(validatePresetComparands({})).toEqual([]);
11+
});
12+
13+
// The exact measured defect shape from #8690: $gte "last_30_days" → 200 / 0.
14+
it('catches a bare preset under $gte in a dashboard widget filter, naming widget, path and fix', () => {
15+
const findings = validatePresetComparands({
16+
dashboards: [{
17+
name: 'sales',
18+
widgets: [{
19+
id: 'won_deals', type: 'metric', dataset: 'deals', values: ['total'],
20+
filter: { closed_at: { $gte: 'last_30_days' } },
21+
}],
22+
}],
23+
});
24+
expect(findings).toHaveLength(1);
25+
const f = findings[0];
26+
expect(f.severity).toBe('error');
27+
expect(f.rule).toBe(FILTER_PRESET_COMPARAND);
28+
expect(f.where).toBe('dashboard "sales" · widget "won_deals"');
29+
expect(f.path).toBe('dashboards[0].widgets[0].filter.closed_at.$gte');
30+
expect(f.message).toContain('last_30_days');
31+
expect(f.message).toContain('{30_days_ago}'); // the spelling that works
32+
expect(f.hint).toContain('{date-macro}');
33+
});
34+
35+
it('judges all three authored filter shapes', () => {
36+
const findings = validatePresetComparands({
37+
views: [{
38+
name: 'recent',
39+
// Shape 2: view filter rules — ordering spelling, alias fold included.
40+
filter: [
41+
{ field: 'created_at', operator: 'after', value: 'last_7_days' },
42+
{ field: 'created_at', operator: 'gte', value: 'last_30_days' },
43+
{ field: 'created_at', operator: 'between', value: ['last_90_days', '2026-01-01'] },
44+
],
45+
}],
46+
pages: [{
47+
name: 'board',
48+
// Shape 3: triples — infix and alias spellings.
49+
components: [
50+
{ type: 'list', filter: ['created_at', '>=', 'last_90_days'] },
51+
{ type: 'list', filter: ['and', ['created_at', 'after', 'this_week'], ['status', '=', 'open']] },
52+
],
53+
}],
54+
flows: [{
55+
name: 'sweep',
56+
// Shape 1: Mongo-style, on a flow CRUD node.
57+
nodes: [{ id: 'find', config: { filter: { updated_at: { $lt: 'last_month' } } } }],
58+
}],
59+
});
60+
const paths = findings.map((f) => f.path).sort();
61+
expect(paths).toEqual([
62+
'flows[0].nodes[0].config.filter.updated_at.$lt',
63+
'pages[0].components[0].filter[2]',
64+
'pages[0].components[1].filter[1][2]',
65+
'views[0].filter[0].value',
66+
'views[0].filter[1].value',
67+
'views[0].filter[2].value[0]',
68+
].sort());
69+
for (const f of findings) expect(f.severity).toBe('error');
70+
});
71+
72+
it('stays quiet on every legitimate spelling — the discriminating controls', () => {
73+
expect(validatePresetComparands({
74+
dashboards: [{
75+
name: 'ok',
76+
widgets: [{
77+
id: 'w', type: 'metric', dataset: 'd', values: ['v'],
78+
// The platform's own correct spellings in the SAME positions.
79+
filter: {
80+
closed_at: { $gte: '{30_days_ago}' },
81+
opened_at: { $between: ['{week_start}', '{week_end}'] },
82+
signed_at: { $lt: '2026-01-15' },
83+
},
84+
}],
85+
}],
86+
views: [{
87+
name: 'v',
88+
filter: [
89+
{ field: 'closed_at', operator: 'after', value: '{30_days_ago}' },
90+
{ field: 'closed_at', operator: 'after', value: '2026-01-15' },
91+
// Equality against a picklist value that collides with a preset
92+
// name is an author's own vocabulary — not this rule's business.
93+
{ field: 'period', operator: 'equals', value: 'this_quarter' },
94+
{ field: 'period', operator: 'in', value: ['last_30_days'] },
95+
],
96+
}],
97+
pages: [{
98+
name: 'p',
99+
components: [
100+
{ type: 'list', filter: ['closed_at', '>=', '{30_days_ago}'] },
101+
// Equality triple — not judged.
102+
{ type: 'list', filter: ['period', '=', 'last_30_days'] },
103+
],
104+
}],
105+
flows: [{
106+
name: 'f',
107+
nodes: [{
108+
id: 'n',
109+
config: {
110+
filter: {
111+
// Equality / membership — not judged (engine door owns the
112+
// temporal-field case, field type in hand).
113+
period: 'this_quarter',
114+
window: { $in: ['last_7_days'] },
115+
// Undeclared string — the field-typed engine door owns it.
116+
updated_at: { $gte: 'not-a-date-at-all' },
117+
// The empty-string cell stays its own card, by ruling.
118+
created_at: { $gte: '' },
119+
},
120+
},
121+
}],
122+
}],
123+
})).toEqual([]);
124+
});
125+
126+
it('walks $and / $or / $not and nested relations in the Mongo shape', () => {
127+
const findings = validatePresetComparands({
128+
objects: [{
129+
name: 'deal',
130+
listViews: [{
131+
name: 'recent',
132+
filter: {
133+
$and: [{ $or: [{ closed_at: { $gte: 'last_7_days' } }] }],
134+
$not: { account: { created_at: { $lt: 'this_year' } } },
135+
},
136+
}],
137+
}],
138+
});
139+
const paths = findings.map((f) => f.path).sort();
140+
expect(paths).toEqual([
141+
'objects[0].listViews[0].filter.$and[0].$or[0].closed_at.$gte',
142+
'objects[0].listViews[0].filter.$not.account.created_at.$lt',
143+
].sort());
144+
});
145+
146+
it('does not double-report a value the token rule already owns', () => {
147+
// `{last_30_days}` is a WRAPPED unknown token — validate-filter-tokens'
148+
// verdict (FILTER_TOKEN_UNKNOWN), not this rule's: a preset name carries
149+
// no braces, so the two vocabularies cannot collide on one value.
150+
expect(validatePresetComparands({
151+
dashboards: [{
152+
name: 'd',
153+
widgets: [{ id: 'w', type: 'metric', dataset: 'x', values: ['v'], filter: { closed_at: { $gte: '{last_30_days}' } } }],
154+
}],
155+
})).toEqual([]);
156+
});
157+
});

0 commit comments

Comments
 (0)