Skip to content

Commit c226e93

Browse files
os-zhuangclaude
andauthored
feat(cli): lint the record-change date-equality time anti-pattern at build (#1874) (#1918)
The templates are AI-authored, so the highest-leverage fix for the "time-relative rule never fires" class is a build-time guardrail that catches the exact mistake, not a new trigger primitive (the robust cron+range pattern already exists). `objectstack build` now emits an advisory WARNING when a `record-*` flow's start condition compares a date field for EQUALITY against a time function (`end_date == daysFromNow(60)`, `today() != ...`) — valid CEL, but it only fires if the record is written on that exact day, so unattended "N days before" rules never run. The warning steers the author to a daily SCHEDULE trigger + range query. Range operators (>=/<=) and non-time equality are NOT flagged; the lint never fails the build (guides, doesn't break legal metadata). New `lint-flow-patterns.ts` + 7 unit tests (flag equality both sides / Expression envelope; do-not-flag range / non-time / schedule trigger). Wired into compile.ts as advisory warnings (mirrors widget-binding warnings). CLI suite 383. Refs #1874 (the declarative time-relative trigger feature + the cross-repo template fixes remain separate; this is the AI-authoring guardrail). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b04b7e3 commit c226e93

4 files changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
feat(cli): build-time lint warns on the record-change date-equality time anti-pattern (#1874)
6+
7+
`objectstack build` now emits an advisory WARNING when a record-change flow's
8+
start condition compares a date field for EQUALITY against a time function
9+
(`end_date == daysFromNow(60)`, `today() != …`). That construct is valid CEL but
10+
a runtime footgun — it only fires if the record happens to be written on that
11+
exact day, so unattended "N days before" rules never run. The warning points the
12+
author to the robust pattern (a daily SCHEDULE trigger + a range query).
13+
14+
Range comparisons (`>=`/`<=`) and non-time-field equality are NOT flagged, and it
15+
never fails the build — it guides authors (very often an AI generating templates)
16+
toward the correct shape without breaking technically-legal metadata.

packages/cli/src/commands/compile.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { loadConfig } from '../utils/config.js';
1010
import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '../utils/validate-expressions.js';
1212
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
13+
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
1314
import { collectAndLintDocs } from '../utils/collect-docs.js';
1415
import { buildRuntimeBundle, cleanupOldRuntimeBundles } from '../utils/build-runtime.js';
1516
import {
@@ -182,6 +183,21 @@ export default class Compile extends Command {
182183
}
183184
}
184185

186+
// 3d. Flow authoring anti-pattern lint (#1874) — advisory warnings for
187+
// valid-but-fragile flow metadata (e.g. a record-change trigger using a
188+
// date-EQUALITY time condition that only fires on the exact day). Guides
189+
// the author — very often an AI generating templates — toward the robust
190+
// pattern; NEVER fails the build.
191+
const flowLint = lintFlowPatterns(result.data as Record<string, unknown>);
192+
if (flowLint.length > 0 && !flags.json) {
193+
console.log('');
194+
for (const fnd of flowLint) {
195+
printWarning(`${fnd.where}: ${fnd.message}`);
196+
console.log(chalk.dim(` ${fnd.hint}`));
197+
console.log(chalk.dim(` rule: ${fnd.rule}`));
198+
}
199+
}
200+
185201
// 3d. Package docs (ADR-0046): compile flat `src/docs/*.md` into
186202
// `docs: DocSchema[]` and lint the combined set (flatness,
187203
// namespace-prefixed names, MDX/image ban, same-package link
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { lintFlowPatterns, FLOW_TIME_RELATIVE_ANTIPATTERN } from './lint-flow-patterns.js';
5+
6+
const flow = (condition: unknown, triggerType = 'record-after-update') => ({
7+
flows: [{
8+
name: 'renewal_alert',
9+
nodes: [{ id: 'start', type: 'start', config: { objectName: 'contract', triggerType, condition } }],
10+
edges: [],
11+
}],
12+
});
13+
14+
describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => {
15+
it('flags record-change date-EQUALITY against a time function', () => {
16+
const fnds = lintFlowPatterns(flow('end_date == daysFromNow(60)'));
17+
expect(fnds).toHaveLength(1);
18+
expect(fnds[0].rule).toBe(FLOW_TIME_RELATIVE_ANTIPATTERN);
19+
expect(fnds[0].where).toContain("renewal_alert");
20+
expect(fnds[0].hint).toMatch(/schedule/i);
21+
});
22+
23+
it('flags the function-on-the-left form too', () => {
24+
expect(lintFlowPatterns(flow('today() != record.start_date'))).toHaveLength(1);
25+
});
26+
27+
it('flags an Expression-envelope condition', () => {
28+
expect(lintFlowPatterns(flow({ dialect: 'cel', source: 'record.due == daysFromNow(7)' }))).toHaveLength(1);
29+
});
30+
31+
describe('does NOT flag (false-positive guards)', () => {
32+
it('a RANGE comparison (the correct building block)', () => {
33+
expect(lintFlowPatterns(flow('end_date <= daysFromNow(60)'))).toHaveLength(0);
34+
expect(lintFlowPatterns(flow('end_date >= daysFromNow(7) && end_date <= daysFromNow(30)'))).toHaveLength(0);
35+
});
36+
it('equality on a non-time field', () => {
37+
expect(lintFlowPatterns(flow('status == "expired"'))).toHaveLength(0);
38+
});
39+
it('a SCHEDULE trigger (only record-* triggers are linted)', () => {
40+
expect(lintFlowPatterns(flow('end_date == daysFromNow(60)', 'schedule'))).toHaveLength(0);
41+
});
42+
it('no condition', () => {
43+
expect(lintFlowPatterns(flow(undefined))).toHaveLength(0);
44+
});
45+
});
46+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build-time lint for flow authoring ANTI-PATTERNS — metadata that is valid
5+
* (passes schema + expression checks) but is semantically a footgun at runtime.
6+
* These are emitted as WARNINGS: they guide the author (very often an AI
7+
* generating templates) toward the robust pattern without failing the build on
8+
* a technically-legal construct.
9+
*
10+
* #1874 — time-relative rules via record-change date-EQUALITY. A start-node
11+
* trigger condition like `end_date == daysFromNow(60)` on a `record-*` trigger
12+
* only fires if the record happens to be written on that exact day; the robust
13+
* shape is a daily SCHEDULE trigger + a range query. We flag the equality form
14+
* specifically (range operators `>=`/`<=` are not flagged — they're the building
15+
* block of the correct pattern), keeping false positives near zero.
16+
*/
17+
18+
export interface FlowLintFinding {
19+
where: string;
20+
message: string;
21+
hint: string;
22+
rule: string;
23+
}
24+
25+
type AnyRec = Record<string, unknown>;
26+
27+
function asArray(v: unknown): AnyRec[] {
28+
if (Array.isArray(v)) return v as AnyRec[];
29+
if (v && typeof v === 'object') return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
30+
return [];
31+
}
32+
33+
/** Extract the raw predicate source from a `condition` (string or Expression envelope). */
34+
function conditionSource(raw: unknown): string {
35+
if (typeof raw === 'string') return raw;
36+
if (raw && typeof raw === 'object' && typeof (raw as AnyRec).source === 'string') return (raw as AnyRec).source as string;
37+
return '';
38+
}
39+
40+
const TIME_FNS = 'daysFromNow|daysAgo|today|now|date|datetime';
41+
// A time function adjacent to an equality operator, either side:
42+
// `end_date == daysFromNow(60)` / `today() != record.start`
43+
const DATE_EQ = new RegExp(
44+
`(?:(?:${TIME_FNS})\\s*\\([^)]*\\)\\s*(?:==|!=))|(?:(?:==|!=)\\s*(?:${TIME_FNS})\\s*\\()`,
45+
);
46+
47+
export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern';
48+
49+
/**
50+
* Lint every flow's start node for known authoring anti-patterns. Returns a
51+
* (possibly empty) list of advisory findings — never throws, never fails a build.
52+
*/
53+
export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
54+
const findings: FlowLintFinding[] = [];
55+
for (const flow of asArray(stack.flows)) {
56+
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
57+
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
58+
const start = nodes.find((n) => n.type === 'start');
59+
if (!start) continue;
60+
const cfg = (start.config ?? {}) as AnyRec;
61+
const triggerType = typeof cfg.triggerType === 'string' ? cfg.triggerType : '';
62+
if (!triggerType.startsWith('record-')) continue;
63+
64+
const src = conditionSource(cfg.condition).trim();
65+
if (src && DATE_EQ.test(src)) {
66+
findings.push({
67+
where: `flow '${flowName}' · start condition`,
68+
message:
69+
`record-change trigger uses a date-EQUALITY time condition (\`${src}\`) — it only fires if the ` +
70+
`record happens to be written on that exact day, so unattended "N days before" rules never run.`,
71+
hint:
72+
`Use a SCHEDULE trigger (daily cron) + a range query instead — e.g. a scheduled flow whose ` +
73+
`get_record filters \`end_date\` BETWEEN {TODAY()} and {TODAY()+N}. (#1874)`,
74+
rule: FLOW_TIME_RELATIVE_ANTIPATTERN,
75+
});
76+
}
77+
}
78+
return findings;
79+
}

0 commit comments

Comments
 (0)