@@ -38,16 +38,82 @@ function conditionSource(raw: unknown): string {
3838}
3939
4040const TIME_FNS = 'daysFromNow|daysAgo|today|now|date|datetime' ;
41+ const TIME_FN_RE = new RegExp ( `\\b(?:${ TIME_FNS } )\\s*\\(` ) ;
4142// A time function adjacent to an equality operator, either side:
4243// `end_date == daysFromNow(60)` / `today() != record.start`
4344const DATE_EQ = new RegExp (
4445 `(?:(?:${ TIME_FNS } )\\s*\\([^)]*\\)\\s*(?:==|!=))|(?:(?:==|!=)\\s*(?:${ TIME_FNS } )\\s*\\()` ,
4546) ;
4647
4748export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern' ;
49+ export const FLOW_DATE_EQUALITY_FILTER = 'flow-date-equality-filter' ;
4850export const FLOW_DOUBLE_BRACE_INTERP = 'flow-double-brace-interpolation' ;
4951export const FLOW_BARE_DOLLAR_REF = 'flow-bare-dollar-reference' ;
5052
53+ /** If `v` is a CEL expression whose source calls a time function, return that source. */
54+ function celTimeSource ( v : unknown ) : string | null {
55+ if ( v && typeof v === 'object' && ( v as AnyRec ) . dialect === 'cel' ) {
56+ const src = ( v as AnyRec ) . source ;
57+ if ( typeof src === 'string' && TIME_FN_RE . test ( src ) ) return src ;
58+ }
59+ return null ;
60+ }
61+
62+ /** Range operators — the building block of the CORRECT time-window pattern, never flagged. */
63+ const RANGE_OPS = new Set ( [ '$gte' , '$gt' , '$lte' , '$lt' , '$ne' ] ) ;
64+
65+ /**
66+ * Walk a get_record/query `filter` for the date-EQUALITY footgun: a field bound
67+ * directly (`field: daysFromNow(N)`) or via `$eq` / `$in` to a time-function value.
68+ * A `Field.date` is stored with a time component, so two independently-computed
69+ * timestamps never compare equal — the query silently returns nothing (#1928 /
70+ * templates #1874). Range operators (`$gte`/`$lt` day windows) are the correct
71+ * shape and are never flagged.
72+ */
73+ function scanFilterForDateEquality (
74+ filter : unknown ,
75+ where : string ,
76+ findings : FlowLintFinding [ ] ,
77+ ) : void {
78+ if ( ! filter || typeof filter !== 'object' || Array . isArray ( filter ) ) return ;
79+ for ( const [ key , val ] of Object . entries ( filter as AnyRec ) ) {
80+ if ( key === '$or' || key === '$and' ) {
81+ if ( Array . isArray ( val ) ) for ( const sub of val ) scanFilterForDateEquality ( sub , where , findings ) ;
82+ continue ;
83+ }
84+ // `key` is a field name; `val` is its constraint.
85+ const direct = celTimeSource ( val ) ; // `field: daysFromNow(N)` → implicit equality
86+ let hit : { op : string ; src : string } | null = direct ? { op : '==' , src : direct } : null ;
87+ if ( ! hit && val && typeof val === 'object' && ( val as AnyRec ) . dialect !== 'cel' ) {
88+ for ( const [ op , operand ] of Object . entries ( val as AnyRec ) ) {
89+ if ( RANGE_OPS . has ( op ) ) continue ; // correct pattern — leave it
90+ if ( op === '$eq' ) {
91+ const s = celTimeSource ( operand ) ;
92+ if ( s ) { hit = { op : '$eq' , src : s } ; break ; }
93+ } else if ( op === '$in' && Array . isArray ( operand ) ) {
94+ for ( const item of operand ) {
95+ const s = celTimeSource ( item ) ;
96+ if ( s ) { hit = { op : '$in' , src : s } ; break ; }
97+ }
98+ if ( hit ) break ;
99+ }
100+ }
101+ }
102+ if ( hit ) {
103+ findings . push ( {
104+ where,
105+ message :
106+ `filter matches \`${ key } \` by ${ hit . op } against a time value (\`${ hit . src } \`) — a date field carries a ` +
107+ `time component, so exact equality against \`${ hit . src } \` (re-computed each run) silently matches nothing.` ,
108+ hint :
109+ `Use a one-day window instead: \`${ key } : { $gte: daysFromNow(N), $lt: daysFromNow(N+1) }\` ` +
110+ `(wrap multiple tiers in \`$or\`). The abutting windows tile the timeline so each row matches exactly once. (#1874)` ,
111+ rule : FLOW_DATE_EQUALITY_FILTER ,
112+ } ) ;
113+ }
114+ }
115+ }
116+
51117// Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` /
52118// `{$User.Id}`). Two wrong-syntax mistakes AI/human authors carry over from the
53119// *formula* template dialect (`{{ path }}`) or other platforms:
@@ -106,9 +172,16 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
106172 // node values use SINGLE braces; double-brace `{{ }}` and bare `$ref.x`
107173 // are carried over from the formula template dialect / other platforms.
108174 for ( const node of nodes ) {
175+ const nodeWhere = `flow '${ flowName } ' · node '${ node . id } ' (${ node . type } )` ;
176+
177+ // (a2) #1874 — date-EQUALITY (`==`/`$eq`/`$in`) against a time value in a
178+ // query filter. A scheduled flow that filters this way silently matches
179+ // nothing; the robust shape is a `$gte`/`$lt` day window.
180+ const cfg = ( node . config ?? { } ) as AnyRec ;
181+ if ( cfg . filter ) scanFilterForDateEquality ( cfg . filter , `${ nodeWhere } filter` , findings ) ;
182+
109183 const strings : string [ ] = [ ] ;
110184 collectTemplateStrings ( node . config , undefined , strings ) ;
111- const nodeWhere = `flow '${ flowName } ' · node '${ node . id } ' (${ node . type } )` ;
112185 for ( const str of strings ) {
113186 if ( DOUBLE_BRACE . test ( str ) ) {
114187 findings . push ( {
0 commit comments