diff --git a/.changeset/lint-date-equality-filter.md b/.changeset/lint-date-equality-filter.md new file mode 100644 index 0000000000..878e6026eb --- /dev/null +++ b/.changeset/lint-date-equality-filter.md @@ -0,0 +1,17 @@ +--- +"@objectstack/cli": minor +--- + +feat(cli): two new flow authoring anti-pattern lints — date-equality filters (#1874) and phantom aggregation (#1870) + +Extends the build-time flow anti-pattern lint (advisory warnings, never fail the build): + +- **flow-date-equality-filter (#1874)**: a get_record/query filter that binds a + field directly, or via `$eq`/`$in`, to a time-function value + (`daysFromNow`/`today`/`now`/…). A `Field.date` stores a time component, so an + exact match against a re-computed timestamp silently returns nothing. Range + operators (`$gte`/`$lt` day windows) are the correct shape and are exempt. +- **flow-phantom-aggregation (#1870)**: a node config key naming a capability the + automation engine does not have (`aggregations`/`aggregate`/`groupBy`/`rollup`/ + `having`). There is no aggregate node, so the key is silently ignored and the + node computes nothing. Points the author to `Field.summary` / `Field.formula`. diff --git a/packages/cli/src/utils/lint-flow-patterns.test.ts b/packages/cli/src/utils/lint-flow-patterns.test.ts index 93d54a6754..63cae54fb4 100644 --- a/packages/cli/src/utils/lint-flow-patterns.test.ts +++ b/packages/cli/src/utils/lint-flow-patterns.test.ts @@ -4,10 +4,25 @@ import { describe, it, expect } from 'vitest'; import { lintFlowPatterns, FLOW_TIME_RELATIVE_ANTIPATTERN, + FLOW_DATE_EQUALITY_FILTER, + FLOW_PHANTOM_AGGREGATION, FLOW_DOUBLE_BRACE_INTERP, FLOW_BARE_DOLLAR_REF, } from './lint-flow-patterns.js'; +const CEL = (source: string) => ({ dialect: 'cel', source }); +/** A scheduled flow with a get_record node carrying `filter`. */ +const filterFlow = (filter: unknown) => ({ + flows: [{ + name: 'expiry_alert', + nodes: [ + { id: 'start', type: 'start', config: { triggerType: 'schedule', schedule: 'cron:0 9 * * *' } }, + { id: 'query', type: 'get_record', config: { objectName: 'contract', filter } }, + ], + edges: [], + }], +}); + const flow = (condition: unknown, triggerType = 'record-after-update') => ({ flows: [{ name: 'renewal_alert', @@ -50,6 +65,82 @@ describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => { }); }); +describe('lintFlowPatterns — date-equality in query filter (#1874)', () => { + it('flags a field bound directly to a time value (implicit equality)', () => { + const fnds = lintFlowPatterns(filterFlow({ expires_at: CEL('daysFromNow(30)') })); + expect(fnds).toHaveLength(1); + expect(fnds[0].rule).toBe(FLOW_DATE_EQUALITY_FILTER); + expect(fnds[0].hint).toMatch(/\$gte.*daysFromNow\(N\).*\$lt/); + }); + + it('flags `$in` against time values (the original renewal_alert bug)', () => { + const fnds = lintFlowPatterns(filterFlow({ + status: 'active', + end_date: { $in: [CEL('daysFromNow(60)'), CEL('daysFromNow(30)'), CEL('daysFromNow(7)')] }, + })); + expect(fnds).toHaveLength(1); + expect(fnds[0].rule).toBe(FLOW_DATE_EQUALITY_FILTER); + }); + + it('flags `$eq` against a time value', () => { + expect(lintFlowPatterns(filterFlow({ d: { $eq: CEL('today()') } }))).toHaveLength(1); + }); + + describe('does NOT flag (false-positive guards)', () => { + it('a one-day window (the correct fix)', () => { + expect(lintFlowPatterns(filterFlow({ + end_date: { $gte: CEL('daysFromNow(7)'), $lt: CEL('daysFromNow(8)') }, + }))).toHaveLength(0); + }); + it('multi-tier windows wrapped in $or', () => { + expect(lintFlowPatterns(filterFlow({ + status: 'active', + $or: [ + { end_date: { $gte: CEL('daysFromNow(7)'), $lt: CEL('daysFromNow(8)') } }, + { end_date: { $gte: CEL('daysFromNow(30)'), $lt: CEL('daysFromNow(31)') } }, + ], + }))).toHaveLength(0); + }); + it('a plain range like `due_date < today()` (overdue query)', () => { + expect(lintFlowPatterns(filterFlow({ status: 'open', due_date: { $lt: CEL('today()') } }))).toHaveLength(0); + }); + it('equality against a non-time value (status, interpolated id)', () => { + expect(lintFlowPatterns(filterFlow({ status: 'active', id: '{record.id}' }))).toHaveLength(0); + expect(lintFlowPatterns(filterFlow({ amount: { $eq: CEL('record.threshold') } }))).toHaveLength(0); + }); + }); +}); + +describe('lintFlowPatterns — phantom aggregation capability (#1870)', () => { + const scriptNode = (config: Record) => ({ + flows: [{ + name: 'rollup', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'sum', type: 'script', config }, + ], + edges: [], + }], + }); + + it('flags `aggregations` on a script node (publication_rollup bug)', () => { + const fnds = lintFlowPatterns(scriptNode({ aggregations: { total: { sum: 'amount' } } })); + expect(fnds).toHaveLength(1); + expect(fnds[0].rule).toBe(FLOW_PHANTOM_AGGREGATION); + expect(fnds[0].hint).toMatch(/Field\.summary/); + }); + + it('flags groupBy / rollup / aggregate / having too', () => { + for (const key of ['groupBy', 'rollup', 'aggregate', 'having']) { + expect(lintFlowPatterns(scriptNode({ [key]: {} })).map((f) => f.rule)).toContain(FLOW_PHANTOM_AGGREGATION); + } + }); + + it('does NOT flag an ordinary script/function node', () => { + expect(lintFlowPatterns(scriptNode({ function: 'helpdesk.triage', inputs: { x: 1 } }))).toHaveLength(0); + }); +}); + /** A flow with a create_record node carrying `config`. */ const nodeFlow = (config: Record) => ({ flows: [{ diff --git a/packages/cli/src/utils/lint-flow-patterns.ts b/packages/cli/src/utils/lint-flow-patterns.ts index 5899e084f5..ea0256556b 100644 --- a/packages/cli/src/utils/lint-flow-patterns.ts +++ b/packages/cli/src/utils/lint-flow-patterns.ts @@ -38,6 +38,7 @@ function conditionSource(raw: unknown): string { } const TIME_FNS = 'daysFromNow|daysAgo|today|now|date|datetime'; +const TIME_FN_RE = new RegExp(`\\b(?:${TIME_FNS})\\s*\\(`); // A time function adjacent to an equality operator, either side: // `end_date == daysFromNow(60)` / `today() != record.start` const DATE_EQ = new RegExp( @@ -45,9 +46,83 @@ const DATE_EQ = new RegExp( ); export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern'; +export const FLOW_DATE_EQUALITY_FILTER = 'flow-date-equality-filter'; +export const FLOW_PHANTOM_AGGREGATION = 'flow-phantom-aggregation'; export const FLOW_DOUBLE_BRACE_INTERP = 'flow-double-brace-interpolation'; export const FLOW_BARE_DOLLAR_REF = 'flow-bare-dollar-reference'; +/** + * Node-config keys that name a capability the automation engine does NOT have. + * There is no aggregate node, so a `script`/`loop`/… node carrying these keys is + * silently ignored — the node runs and computes nothing (templates #1870, + * `publication_rollup`). Aggregation belongs in the data layer, not a flow. + */ +const PHANTOM_AGG_KEYS = new Set(['aggregations', 'aggregate', 'groupBy', 'rollup', 'having']); + +/** If `v` is a CEL expression whose source calls a time function, return that source. */ +function celTimeSource(v: unknown): string | null { + if (v && typeof v === 'object' && (v as AnyRec).dialect === 'cel') { + const src = (v as AnyRec).source; + if (typeof src === 'string' && TIME_FN_RE.test(src)) return src; + } + return null; +} + +/** Range operators — the building block of the CORRECT time-window pattern, never flagged. */ +const RANGE_OPS = new Set(['$gte', '$gt', '$lte', '$lt', '$ne']); + +/** + * Walk a get_record/query `filter` for the date-EQUALITY footgun: a field bound + * directly (`field: daysFromNow(N)`) or via `$eq` / `$in` to a time-function value. + * A `Field.date` is stored with a time component, so two independently-computed + * timestamps never compare equal — the query silently returns nothing (#1928 / + * templates #1874). Range operators (`$gte`/`$lt` day windows) are the correct + * shape and are never flagged. + */ +function scanFilterForDateEquality( + filter: unknown, + where: string, + findings: FlowLintFinding[], +): void { + if (!filter || typeof filter !== 'object' || Array.isArray(filter)) return; + for (const [key, val] of Object.entries(filter as AnyRec)) { + if (key === '$or' || key === '$and') { + if (Array.isArray(val)) for (const sub of val) scanFilterForDateEquality(sub, where, findings); + continue; + } + // `key` is a field name; `val` is its constraint. + const direct = celTimeSource(val); // `field: daysFromNow(N)` → implicit equality + let hit: { op: string; src: string } | null = direct ? { op: '==', src: direct } : null; + if (!hit && val && typeof val === 'object' && (val as AnyRec).dialect !== 'cel') { + for (const [op, operand] of Object.entries(val as AnyRec)) { + if (RANGE_OPS.has(op)) continue; // correct pattern — leave it + if (op === '$eq') { + const s = celTimeSource(operand); + if (s) { hit = { op: '$eq', src: s }; break; } + } else if (op === '$in' && Array.isArray(operand)) { + for (const item of operand) { + const s = celTimeSource(item); + if (s) { hit = { op: '$in', src: s }; break; } + } + if (hit) break; + } + } + } + if (hit) { + findings.push({ + where, + message: + `filter matches \`${key}\` by ${hit.op} against a time value (\`${hit.src}\`) — a date field carries a ` + + `time component, so exact equality against \`${hit.src}\` (re-computed each run) silently matches nothing.`, + hint: + `Use a one-day window instead: \`${key}: { $gte: daysFromNow(N), $lt: daysFromNow(N+1) }\` ` + + `(wrap multiple tiers in \`$or\`). The abutting windows tile the timeline so each row matches exactly once. (#1874)`, + rule: FLOW_DATE_EQUALITY_FILTER, + }); + } + } +} + // Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` / // `{$User.Id}`). Two wrong-syntax mistakes AI/human authors carry over from the // *formula* template dialect (`{{ path }}`) or other platforms: @@ -106,9 +181,34 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] { // node values use SINGLE braces; double-brace `{{ }}` and bare `$ref.x` // are carried over from the formula template dialect / other platforms. for (const node of nodes) { + const nodeWhere = `flow '${flowName}' · node '${node.id}' (${node.type})`; + + // (a2) #1874 — date-EQUALITY (`==`/`$eq`/`$in`) against a time value in a + // query filter. A scheduled flow that filters this way silently matches + // nothing; the robust shape is a `$gte`/`$lt` day window. + const cfg = (node.config ?? {}) as AnyRec; + if (cfg.filter) scanFilterForDateEquality(cfg.filter, `${nodeWhere} filter`, findings); + + // (a3) #1870 — a node-config key naming a non-existent capability (there is + // no aggregate node) is silently ignored at runtime, so the node + // computes nothing. Point the author at the data-layer equivalent. + for (const key of Object.keys(cfg)) { + if (PHANTOM_AGG_KEYS.has(key)) { + findings.push({ + where: nodeWhere, + message: + `node config has \`${key}\` — the automation engine has no aggregate node, so \`${key}\` is ` + + `silently ignored and this node computes nothing at runtime.`, + hint: + `Aggregation belongs in the data layer: use \`Field.summary\` for a cross-object rollup ` + + `(sum/count of children), or \`Field.formula\` for a per-record computed value. (#1870)`, + rule: FLOW_PHANTOM_AGGREGATION, + }); + } + } + const strings: string[] = []; collectTemplateStrings(node.config, undefined, strings); - const nodeWhere = `flow '${flowName}' · node '${node.id}' (${node.type})`; for (const str of strings) { if (DOUBLE_BRACE.test(str)) { findings.push({