Skip to content

Commit a1d6bfe

Browse files
committed
feat(formula,lint): warn on date field == temporal-function silent-miss (#3183)
A `Field.date` deserializes as a "YYYY-MM-DD" string (ADR-0053 Phase 1), and cel-js's equality hard-codes `string == <timestamp>` to false, so `record.due_date == today()` compiles clean but silently never matches. cel-js offers no operator-layer hook to fix the comparison, so this adds a build-time advisory warning (the established ADR-0032 guardrail strategy) that turns the silent runtime miss into a loud, self-correcting build signal. - formula: `ExprSchemaHint.dateFields`; `validateExpression` flags `==`/`!=` between a named `date` field and today()/daysFromNow()/daysAgo()/now(), with a message pointing at the working idioms (date(...), a range, or daysBetween). Warning severity — the write/validation path may carry a real Date, so it never fails the build. Ordering operators already work (they fault→hydrate) and are not flagged. - lint: `validateStackExpressions` threads each object's `date` field names into the validator so the warning fires across formulas, validations, flow/action/ sharing/hook predicates. Restricted to `type: 'date'` (unambiguously a string); `datetime` excluded to avoid false positives. A runtime fix (data-layer date hydration) stays tracked in #3183 — a naive version would trade this silent-miss for another (breaking `dateField == "literal"`), so it needs its own design. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SuiM565BZ3TR1VD3prMguB
1 parent fefcd54 commit a1d6bfe

5 files changed

Lines changed: 267 additions & 2 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/lint": minor
4+
---
5+
6+
feat(formula,lint): warn when a `date` field is compared to a temporal function with `==`/`!=` (#3183)
7+
8+
A `Field.date` deserializes as a `YYYY-MM-DD` **string** (ADR-0053 Phase 1), and
9+
cel-js's equality hard-codes `string == <timestamp>` to `false` — it returns
10+
`false` for a string left operand without ever consulting a registered overload,
11+
and refuses cross-type object equality (`@marcbachmann/cel-js` `overloads.js`
12+
`isEqual`). So the most natural "is it due today" predicate —
13+
14+
```cel
15+
record.due_date == today() // silently false, even when due_date IS today
16+
record.due_date != today() // silently true for a same-day record
17+
```
18+
19+
— compiles clean, throws nothing, and silently never matches. This is the
20+
silent-miss class the ADR-0032 guardrails exist to eliminate (same family as
21+
#1928), and it is **timezone-independent** (fails identically at UTC) and
22+
cross-cutting (every CEL site: formulas, validation, RLS, flow, action predicates).
23+
24+
cel-js gives no hook to fix the comparison in the operator layer, so this adds a
25+
**build-time advisory warning** (the established guardrail strategy) rather than a
26+
runtime behavior change:
27+
28+
- `@objectstack/formula``ExprSchemaHint` gains `dateFields`; `validateExpression`
29+
flags a `==`/`!=` between a named `date` field (`record.`/`previous.`/bare) and
30+
`today()`/`daysFromNow()`/`daysAgo()`/`now()`, with a self-correcting message
31+
pointing at the working idioms (`date(record.d) == today()`, a range comparison,
32+
or `daysBetween(today(), record.d) == 0`). Warning severity — never fails the
33+
build, since on the write/validation path the value may legitimately be a `Date`.
34+
- `@objectstack/lint``validateStackExpressions` threads each object's `date`
35+
field names into the validator so the warning fires across every predicate site.
36+
Restricted to `type: 'date'` (unambiguously a string); `datetime` is excluded to
37+
avoid false positives.
38+
39+
The ordering operators (`>=`/`<=`/`<`/`>`) already work — cel-js *throws* for them,
40+
which trips the engine's existing string-hydration retry — so they are not flagged.
41+
A runtime fix (hydrating date fields to `Date` in the data layer) remains tracked
42+
in #3183; a naive version would trade this silent-miss for another (breaking
43+
`dateField == "2026-06-20"`), so it needs its own design.

packages/formula/src/validate.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,3 +207,55 @@ describe('inferExpressionType — coarse value-type of a formula', () => {
207207
expect(inferExpressionType('undeclared_field + 1')).toBe('unknown'); // bare ref, no fields given
208208
});
209209
});
210+
211+
// #3183 — a `Field.date` reads back as a "YYYY-MM-DD" string, and cel-js's
212+
// equality hard-codes `string == <timestamp>` to false, so `record.due_date ==
213+
// today()` silently never matches. Advisory warning that guides the author to a
214+
// working idiom. Only fires when `dateFields` names the field as calendar-day.
215+
describe('temporal date-equality guardrail (#3183)', () => {
216+
const schema = { objectName: 'task', fields: ['due_date', 'status'], dateFields: ['due_date'], scope: 'record' as const };
217+
218+
const warns = (src: string, s = schema) => validateExpression('predicate', src, s).warnings;
219+
220+
it('warns on `dateField == today()` (field on the left)', () => {
221+
const r = validateExpression('predicate', 'record.due_date == today()', schema);
222+
expect(r.ok).toBe(true); // advisory — never fails the build
223+
expect(r.errors).toHaveLength(0);
224+
expect(r.warnings).toHaveLength(1);
225+
expect(r.warnings[0].message).toMatch(/calendar-day \(date\) field/);
226+
expect(r.warnings[0].message).toMatch(/date\(record\.due_date\) == today\(\)/);
227+
});
228+
229+
it('warns on `today() == dateField` (field on the right) and on daysFromNow/daysAgo/now', () => {
230+
expect(warns('today() == record.due_date')).toHaveLength(1);
231+
expect(warns('record.due_date == daysFromNow(3)')).toHaveLength(1);
232+
expect(warns('record.due_date != daysAgo(7)')).toHaveLength(1);
233+
expect(warns('record.due_date == now()')).toHaveLength(1);
234+
});
235+
236+
it('warns on a bare date-field ref (flattened flow-condition scope)', () => {
237+
const flat = { objectName: 'task', fields: ['due_date'], dateFields: ['due_date'] };
238+
expect(warns('due_date == today()', flat)).toHaveLength(1);
239+
});
240+
241+
it('does NOT warn on the working idioms', () => {
242+
expect(warns('date(record.due_date) == today()')).toHaveLength(0);
243+
expect(warns('record.due_date >= today() && record.due_date <= today()')).toHaveLength(0);
244+
expect(warns('daysBetween(today(), record.due_date) == 0')).toHaveLength(0);
245+
});
246+
247+
it('does NOT warn on ordering comparisons (they fault→hydrate and work)', () => {
248+
expect(warns('record.due_date >= today()')).toHaveLength(0);
249+
expect(warns('record.due_date < daysFromNow(30)')).toHaveLength(0);
250+
});
251+
252+
it('does NOT warn on a non-date field, or when dateFields is absent', () => {
253+
expect(warns('record.status == today()')).toHaveLength(0); // status not a date field
254+
expect(warns('record.due_date == today()', { objectName: 'task', fields: ['due_date'], scope: 'record' as const }))
255+
.toHaveLength(0); // no dateFields supplied → check skipped
256+
});
257+
258+
it('de-duplicates repeated references to the same field', () => {
259+
expect(warns('record.due_date == today() || record.due_date == daysFromNow(1)')).toHaveLength(1);
260+
});
261+
});

packages/formula/src/validate.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,17 @@ export interface ExprSchemaHint {
6161
* role that then silently never matches. Absent => role checks are skipped.
6262
*/
6363
roleCatalog?: readonly string[];
64+
/**
65+
* Names of `date`-typed (calendar-day) fields on the object. A `Field.date`
66+
* deserializes as a `YYYY-MM-DD` **string** (ADR-0053 Phase 1), and cel-js's
67+
* equality hard-codes `string == <timestamp>` to false, so a bare
68+
* `record.<dateField> == today()` **silently never matches** (#3183). When
69+
* supplied, such `==`/`!=` comparisons against a temporal function are flagged
70+
* with the corrective idiom. A non-blocking *warning*: on the write/validation
71+
* path the value may legitimately be a `Date`, so this must not fail the build.
72+
* Absent => the check is skipped.
73+
*/
74+
dateFields?: readonly string[];
6475
}
6576

6677
export interface ExprValidationError {
@@ -84,6 +95,15 @@ export interface ExprValidationResult {
8495

8596
/** A bare `{x}` that is NOT part of a `{{x}}` mustache hole. */
8697
const SINGLE_BRACE_RE = /(?:^|[^{])\{\s*([A-Za-z_$][\w.$]*)\s*\}(?!\})/;
98+
// #3183 — a date field reference (`record.`/`previous.`/bare) compared with
99+
// `==`/`!=` against a calendar-day temporal function. Two forms (field on either
100+
// side). Bounded quantifiers only (`[\w$]*`, a single `[^)]*`) → linear-time, no
101+
// ReDoS. The `date(...)`/`datetime(...)`/`timestamp(...)` coercions are NOT in the
102+
// temporal set, so the recommended idiom `date(record.d) == today()` is not flagged.
103+
const TEMPORAL_EQ_LEFT_RE =
104+
/(?:record\.|previous\.)?([A-Za-z_$][\w$]*)\s*(?:==|!=)\s*(?:today|daysFromNow|daysAgo|now)\s*\(/g;
105+
const TEMPORAL_EQ_RIGHT_RE =
106+
/(?:today|daysFromNow|daysAgo|now)\s*\([^)]*\)\s*(?:==|!=)\s*(?:record\.|previous\.)?([A-Za-z_$][\w$]*)/g;
87107
/** `record.<field>` / `previous.<field>` head references for field-existence. */
88108
const RECORD_REF_RE = /\b(?:record|previous)\.([A-Za-z_$][\w$]*)/g;
89109

@@ -199,6 +219,43 @@ function checkRoleCatalog(
199219
}
200220
}
201221

222+
/**
223+
* Flag `==`/`!=` comparisons of a calendar-day (`date`) field against a temporal
224+
* function (`today()`/`daysFromNow()`/`daysAgo()`/`now()`) — the #3183 silent-miss.
225+
* A `Field.date` reads back as a `YYYY-MM-DD` string, and cel-js's equality
226+
* (`overloads.js` `isEqual`) treats a string and a timestamp as unequal without
227+
* ever consulting an overload, so the comparison silently never matches. Advisory
228+
* `warning` only (the value may be a `Date` on the write path); no-op unless
229+
* `schema.dateFields` is supplied.
230+
*/
231+
function checkTemporalDateEquality(
232+
source: string,
233+
schema: ExprSchemaHint | undefined,
234+
warnings: ExprValidationError[],
235+
): void {
236+
const dateFields = schema?.dateFields;
237+
if (!dateFields || dateFields.length === 0) return;
238+
const dateSet = new Set(dateFields);
239+
const seen = new Set<string>();
240+
const flag = (field: string): void => {
241+
if (!dateSet.has(field) || seen.has(field)) return;
242+
seen.add(field);
243+
warnings.push({
244+
source,
245+
message:
246+
`\`${field}\` is a calendar-day (date) field, stored as a "YYYY-MM-DD" string, so comparing it to a ` +
247+
`timestamp function with \`==\`/\`!=\` silently never matches (CEL treats a string and a timestamp as ` +
248+
`unequal). Wrap the field to coerce it — \`date(record.${field}) == today()\` — or use a range ` +
249+
`(\`record.${field} >= today() && record.${field} <= today()\`) or \`daysBetween(today(), record.${field}) == 0\`.`,
250+
});
251+
};
252+
let m: RegExpExecArray | null;
253+
TEMPORAL_EQ_LEFT_RE.lastIndex = 0;
254+
while ((m = TEMPORAL_EQ_LEFT_RE.exec(source)) !== null) flag(m[1]);
255+
TEMPORAL_EQ_RIGHT_RE.lastIndex = 0;
256+
while ((m = TEMPORAL_EQ_RIGHT_RE.exec(source)) !== null) flag(m[1]);
257+
}
258+
202259
/**
203260
* Validate one expression for a given field role. Never throws — returns a
204261
* structured result. Call sites decide whether to throw (build/registration)
@@ -248,6 +305,7 @@ export function validateExpression(
248305
} else {
249306
checkFieldExistence(source, schema, errors);
250307
checkRoleCatalog(source, schema, errors);
308+
checkTemporalDateEquality(source, schema, warnings);
251309
if (schema?.scope === 'record') {
252310
// In a `record`-scoped site a bare top-level identifier is a silent bug —
253311
// it must be `record.<field>` (#1928). Hard error.

packages/lint/src/validate-expressions.test.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,4 +394,85 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
394394
expect(issues.some(i => /columnName/.test(i.message))).toBe(false);
395395
});
396396
});
397+
398+
// #3183 — `date` field `== today()` silently never matches (cel-js equality).
399+
// The stack validator threads the object's `date` field names into the shared
400+
// validator, which flags the pattern with an advisory (non-blocking) warning.
401+
describe('date-field equality guardrail (#3183)', () => {
402+
const objects = [{
403+
name: 'task',
404+
fields: { due_date: { type: 'date' }, title: { type: 'text' } },
405+
}];
406+
407+
it('warns on a formula field comparing a date field to today()', () => {
408+
const issues = validateStackExpressions({
409+
objects: [{
410+
name: 'task',
411+
fields: {
412+
due_date: { type: 'date' },
413+
is_due_today: { type: 'formula', formula: 'record.due_date == today()' },
414+
},
415+
}],
416+
});
417+
const w = issues.filter(i => /calendar-day \(date\) field/.test(i.message));
418+
expect(w).toHaveLength(1);
419+
expect(w[0].severity).toBe('warning');
420+
expect(w[0].where).toMatch(/object 'task'.*formula/);
421+
});
422+
423+
it('warns on a validation-rule predicate, and never fails the build (advisory)', () => {
424+
const issues = validateStackExpressions({
425+
objects: [{
426+
name: 'task',
427+
fields: { due_date: { type: 'date' } },
428+
validations: [{ name: 'due_check', expression: 'record.due_date != today()' }],
429+
}],
430+
});
431+
const w = issues.filter(i => /calendar-day \(date\) field/.test(i.message));
432+
expect(w).toHaveLength(1);
433+
expect(w.every(i => i.severity === 'warning')).toBe(true);
434+
});
435+
436+
it('warns on a flattened flow condition using a bare date field', () => {
437+
const issues = validateStackExpressions({
438+
objects,
439+
flows: [{
440+
name: 'due_flow',
441+
nodes: [
442+
{ id: 'start', type: 'start', config: { objectName: 'task' } },
443+
{ id: 'check', type: 'decision', config: { condition: 'due_date == today()' } },
444+
],
445+
edges: [],
446+
}],
447+
});
448+
expect(issues.filter(i => /calendar-day \(date\) field/.test(i.message))).toHaveLength(1);
449+
});
450+
451+
it('does not warn on the working idioms or ordering comparisons', () => {
452+
const issues = validateStackExpressions({
453+
objects: [{
454+
name: 'task',
455+
fields: {
456+
due_date: { type: 'date' },
457+
due_soon: { type: 'formula', formula: 'record.due_date >= today() && record.due_date <= daysFromNow(7)' },
458+
is_today: { type: 'formula', formula: 'date(record.due_date) == today()' },
459+
},
460+
}],
461+
});
462+
expect(issues.filter(i => /calendar-day \(date\) field/.test(i.message))).toHaveLength(0);
463+
});
464+
465+
it('does not warn when the compared field is a datetime (may be a real instant)', () => {
466+
const issues = validateStackExpressions({
467+
objects: [{
468+
name: 'task',
469+
fields: {
470+
signed_at: { type: 'datetime' },
471+
just_signed: { type: 'formula', formula: 'record.signed_at == now()' },
472+
},
473+
}],
474+
});
475+
expect(issues.filter(i => /calendar-day \(date\) field/.test(i.message))).toHaveLength(0);
476+
});
477+
});
397478
});

packages/lint/src/validate-expressions.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
5555
return idx;
5656
}
5757

58+
/**
59+
* object name → its `date`-typed (calendar-day) field names (#3183). A `Field.date`
60+
* deserializes as a `YYYY-MM-DD` string, so an `==`/`!=` against a temporal function
61+
* silently never matches; the shared validator flags it when these names are passed.
62+
* Restricted to `type: 'date'` — a `datetime` may deserialize as a real instant, so
63+
* flagging it could false-positive; `date` is unambiguously a string.
64+
*/
65+
function buildDateFieldIndex(objects: AnyRec[]): Map<string, string[]> {
66+
const idx = new Map<string, string[]>();
67+
for (const obj of objects) {
68+
const name = typeof obj.name === 'string' ? obj.name : undefined;
69+
if (!name) continue;
70+
const fields = obj.fields;
71+
const dateNames: string[] = [];
72+
if (Array.isArray(fields)) {
73+
for (const f of fields) {
74+
const fr = f as AnyRec;
75+
if (typeof fr.name === 'string' && fr.type === 'date') dateNames.push(fr.name);
76+
}
77+
} else if (fields && typeof fields === 'object') {
78+
for (const [fname, fdef] of Object.entries(fields as AnyRec)) {
79+
if (fdef && typeof fdef === 'object' && (fdef as AnyRec).type === 'date') dateNames.push(fname);
80+
}
81+
}
82+
idx.set(name, dateNames);
83+
}
84+
return idx;
85+
}
86+
5887
/**
5988
* Validate every predicate in the stack. Returns the list of issues (empty =
6089
* clean). Caller decides how to surface / whether to fail the build.
@@ -63,6 +92,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
6392
const issues: ExprIssue[] = [];
6493
const objects = asArray(stack.objects);
6594
const fieldIndex = buildFieldIndex(objects);
95+
const dateFieldIndex = buildDateFieldIndex(objects);
6696

6797
const check = (
6898
where: string,
@@ -72,8 +102,9 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
72102
): void => {
73103
if (raw == null) return;
74104
const fields = objectName ? fieldIndex.get(objectName) : undefined;
105+
const dateFields = objectName ? dateFieldIndex.get(objectName) : undefined;
75106
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string },
76-
objectName ? { objectName, fields, scope } : { scope });
107+
objectName ? { objectName, fields, dateFields, scope } : { scope });
77108
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: 'error' });
78109
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
79110
};
@@ -193,7 +224,7 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
193224
// formulas are `value` role (any return type), still CEL. They are
194225
// `record`-scoped — `record.<field>`, never bare — so flag bare refs (#1928).
195226
const res = validateExpression('value', f.formula as string | { dialect?: string; source?: string },
196-
objectName ? { objectName, fields: fieldIndex.get(objectName), scope: 'record' } : { scope: 'record' });
227+
objectName ? { objectName, fields: fieldIndex.get(objectName), dateFields: dateFieldIndex.get(objectName), scope: 'record' } : { scope: 'record' });
197228
const fieldWhere = `object '${objectName}' · field '${(f.name as string) ?? '?'}' formula`;
198229
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' });
199230
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });

0 commit comments

Comments
 (0)