Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/flow-time-relative-antipattern-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@objectstack/cli": patch
---

feat(cli): build-time lint warns on the record-change date-equality time anti-pattern (#1874)

`objectstack build` now emits an advisory WARNING when a record-change flow's
start condition compares a date field for EQUALITY against a time function
(`end_date == daysFromNow(60)`, `today() != …`). That construct is valid CEL but
a runtime footgun — it only fires if the record happens to be written on that
exact day, so unattended "N days before" rules never run. The warning points the
author to the robust pattern (a daily SCHEDULE trigger + a range query).

Range comparisons (`>=`/`<=`) and non-time-field equality are NOT flagged, and it
never fails the build — it guides authors (very often an AI generating templates)
toward the correct shape without breaking technically-legal metadata.
16 changes: 16 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { loadConfig } from '../utils/config.js';
import { lowerCallables } from '../utils/lower-callables.js';
import { validateStackExpressions } from '../utils/validate-expressions.js';
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
import {
Expand Down Expand Up @@ -182,6 +183,21 @@ export default class Compile extends Command {
}
}

// 3d. Flow authoring anti-pattern lint (#1874) — advisory warnings for
// valid-but-fragile flow metadata (e.g. a record-change trigger using a
// date-EQUALITY time condition that only fires on the exact day). Guides
// the author — very often an AI generating templates — toward the robust
// pattern; NEVER fails the build.
const flowLint = lintFlowPatterns(result.data as Record<string, unknown>);
if (flowLint.length > 0 && !flags.json) {
console.log('');
for (const fnd of flowLint) {
printWarning(`${fnd.where}: ${fnd.message}`);
console.log(chalk.dim(` ${fnd.hint}`));
console.log(chalk.dim(` rule: ${fnd.rule}`));
}
}

// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
// `docs: DocSchema[]` and lint the combined set (flatness,
// namespace-prefixed names, MDX/image ban, same-package link
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/utils/lint-flow-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { lintFlowPatterns, FLOW_TIME_RELATIVE_ANTIPATTERN } from './lint-flow-patterns.js';

const flow = (condition: unknown, triggerType = 'record-after-update') => ({
flows: [{
name: 'renewal_alert',
nodes: [{ id: 'start', type: 'start', config: { objectName: 'contract', triggerType, condition } }],
edges: [],
}],
});

describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => {
it('flags record-change date-EQUALITY against a time function', () => {
const fnds = lintFlowPatterns(flow('end_date == daysFromNow(60)'));
expect(fnds).toHaveLength(1);
expect(fnds[0].rule).toBe(FLOW_TIME_RELATIVE_ANTIPATTERN);
expect(fnds[0].where).toContain("renewal_alert");
expect(fnds[0].hint).toMatch(/schedule/i);
});

it('flags the function-on-the-left form too', () => {
expect(lintFlowPatterns(flow('today() != record.start_date'))).toHaveLength(1);
});

it('flags an Expression-envelope condition', () => {
expect(lintFlowPatterns(flow({ dialect: 'cel', source: 'record.due == daysFromNow(7)' }))).toHaveLength(1);
});

describe('does NOT flag (false-positive guards)', () => {
it('a RANGE comparison (the correct building block)', () => {
expect(lintFlowPatterns(flow('end_date <= daysFromNow(60)'))).toHaveLength(0);
expect(lintFlowPatterns(flow('end_date >= daysFromNow(7) && end_date <= daysFromNow(30)'))).toHaveLength(0);
});
it('equality on a non-time field', () => {
expect(lintFlowPatterns(flow('status == "expired"'))).toHaveLength(0);
});
it('a SCHEDULE trigger (only record-* triggers are linted)', () => {
expect(lintFlowPatterns(flow('end_date == daysFromNow(60)', 'schedule'))).toHaveLength(0);
});
it('no condition', () => {
expect(lintFlowPatterns(flow(undefined))).toHaveLength(0);
});
});
});
79 changes: 79 additions & 0 deletions packages/cli/src/utils/lint-flow-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Build-time lint for flow authoring ANTI-PATTERNS — metadata that is valid
* (passes schema + expression checks) but is semantically a footgun at runtime.
* These are emitted as WARNINGS: they guide the author (very often an AI
* generating templates) toward the robust pattern without failing the build on
* a technically-legal construct.
*
* #1874 — time-relative rules via record-change date-EQUALITY. A start-node
* trigger condition like `end_date == daysFromNow(60)` on a `record-*` trigger
* only fires if the record happens to be written on that exact day; the robust
* shape is a daily SCHEDULE trigger + a range query. We flag the equality form
* specifically (range operators `>=`/`<=` are not flagged — they're the building
* block of the correct pattern), keeping false positives near zero.
*/

export interface FlowLintFinding {
where: string;
message: string;
hint: string;
rule: string;
}

type AnyRec = Record<string, unknown>;

function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
return [];
}

/** Extract the raw predicate source from a `condition` (string or Expression envelope). */
function conditionSource(raw: unknown): string {
if (typeof raw === 'string') return raw;
if (raw && typeof raw === 'object' && typeof (raw as AnyRec).source === 'string') return (raw as AnyRec).source as string;
return '';
}

const TIME_FNS = 'daysFromNow|daysAgo|today|now|date|datetime';
// A time function adjacent to an equality operator, either side:
// `end_date == daysFromNow(60)` / `today() != record.start`
const DATE_EQ = new RegExp(
`(?:(?:${TIME_FNS})\\s*\\([^)]*\\)\\s*(?:==|!=))|(?:(?:==|!=)\\s*(?:${TIME_FNS})\\s*\\()`,
);

export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern';

/**
* Lint every flow's start node for known authoring anti-patterns. Returns a
* (possibly empty) list of advisory findings — never throws, never fails a build.
*/
export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
const findings: FlowLintFinding[] = [];
for (const flow of asArray(stack.flows)) {
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
const start = nodes.find((n) => n.type === 'start');
if (!start) continue;
const cfg = (start.config ?? {}) as AnyRec;
const triggerType = typeof cfg.triggerType === 'string' ? cfg.triggerType : '';
if (!triggerType.startsWith('record-')) continue;

const src = conditionSource(cfg.condition).trim();
if (src && DATE_EQ.test(src)) {
findings.push({
where: `flow '${flowName}' · start condition`,
message:
`record-change trigger uses a date-EQUALITY time condition (\`${src}\`) — it only fires if the ` +
`record happens to be written on that exact day, so unattended "N days before" rules never run.`,
hint:
`Use a SCHEDULE trigger (daily cron) + a range query instead — e.g. a scheduled flow whose ` +
`get_record filters \`end_date\` BETWEEN {TODAY()} and {TODAY()+N}. (#1874)`,
rule: FLOW_TIME_RELATIVE_ANTIPATTERN,
});
}
}
return findings;
}
Loading