Skip to content

Commit 266c0f8

Browse files
os-zhuangclaude
andauthored
feat(cli): lint wrong flow-value interpolation syntax (double-brace / bare $ref) (#1315) (#1920)
Second AI-authoring guardrail in lintFlowPatterns (after #1874). Flow node VALUES interpolate with SINGLE braces; AI/human authors carry over two wrong syntaxes from the formula template dialect / other platforms: - `{{ai_reply}}` double-brace — verified no flow node executor uses `{{ }}`, so it's unambiguously wrong in a flow value (it's the formula/template-field dialect). Warn → use `{var}`. - bare `$source.id` — a `$`-prefixed reference written as a literal (not interpolated). Warn → wrap as `{source.id}` / `{$User.Id}`. Precise/low-false-positive: braced `{$User.Id}` and currency `$5.00` are NOT flagged (the `$`-rule requires a letter after `$` and a non-`{` lead char); CEL condition/expression keys are skipped (not template values); advisory only, never fails the build. +6 unit tests; CLI suite 396; example-crm/app-todo/ showcase build with zero spurious warnings. Refs #1315 (closed — semantics were consistent; this is the build-time safety net that catches the wrong-syntax mistakes from its repro). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c226e93 commit 266c0f8

3 files changed

Lines changed: 134 additions & 17 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
feat(cli): build lint warns on wrong flow-value interpolation syntax (double-brace / bare `$ref`) (#1315)
6+
7+
Extends the flow authoring anti-pattern lint with two advisory WARNINGs for the
8+
interpolation-syntax mistakes AI/human authors carry over from other dialects:
9+
10+
- **double-brace** `{{ai_reply}}` in a flow node value — flow node values use
11+
SINGLE braces (`{var}`); `{{ }}` is the formula/template-field dialect, never
12+
flow node values (verified: no flow node executor uses `{{ }}`).
13+
- **bare `$ref.field`** (e.g. `$source.id`) written as a plain value — it's not
14+
interpolated; the author meant `{source.id}` (or `{$User.Id}`).
15+
16+
Precise: single-brace interpolation, braced `{$User.Id}`, currency literals
17+
(`$5.00`), and CEL condition fields are NOT flagged; never fails the build.

packages/cli/src/utils/lint-flow-patterns.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { describe, it, expect } from 'vitest';
4-
import { lintFlowPatterns, FLOW_TIME_RELATIVE_ANTIPATTERN } from './lint-flow-patterns.js';
4+
import {
5+
lintFlowPatterns,
6+
FLOW_TIME_RELATIVE_ANTIPATTERN,
7+
FLOW_DOUBLE_BRACE_INTERP,
8+
FLOW_BARE_DOLLAR_REF,
9+
} from './lint-flow-patterns.js';
510

611
const flow = (condition: unknown, triggerType = 'record-after-update') => ({
712
flows: [{
@@ -44,3 +49,44 @@ describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => {
4449
});
4550
});
4651
});
52+
53+
/** A flow with a create_record node carrying `config`. */
54+
const nodeFlow = (config: Record<string, unknown>) => ({
55+
flows: [{
56+
name: 'mk',
57+
nodes: [
58+
{ id: 'start', type: 'start', config: {} },
59+
{ id: 'create', type: 'create_record', config },
60+
],
61+
edges: [],
62+
}],
63+
});
64+
const rules = (s: any) => lintFlowPatterns(s).map((f) => f.rule);
65+
66+
describe('lintFlowPatterns — wrong interpolation syntax (#1315)', () => {
67+
it('flags double-brace interpolation in a node value', () => {
68+
expect(rules(nodeFlow({ objectName: 'm', fields: { body: '{{ai_reply}}' } })))
69+
.toContain(FLOW_DOUBLE_BRACE_INTERP);
70+
});
71+
it('flags a bare $ref.field written as a literal', () => {
72+
expect(rules(nodeFlow({ objectName: 'm', fields: { ticket: '$source.id' } })))
73+
.toContain(FLOW_BARE_DOLLAR_REF);
74+
});
75+
describe('does NOT flag (false-positive guards)', () => {
76+
it('correct single-brace interpolation', () => {
77+
expect(rules(nodeFlow({ objectName: 'm', fields: { body: '{ai_reply}', t: 'Hi {record.name}' } }))).toEqual([]);
78+
});
79+
it('a braced $User reference', () => {
80+
expect(rules(nodeFlow({ objectName: 'm', fields: { owner: '{$User.Id}' } }))).toEqual([]);
81+
});
82+
it('a currency literal', () => {
83+
expect(rules(nodeFlow({ objectName: 'm', fields: { price: '$5.00', label: 'Total $5' } }))).toEqual([]);
84+
});
85+
it('a CEL condition (skipped — not a template value)', () => {
86+
expect(rules({ flows: [{ name: 'd', nodes: [
87+
{ id: 'start', type: 'start', config: {} },
88+
{ id: 'dec', type: 'decision', config: { condition: 'record.amount > 100' } },
89+
], edges: [] }] })).toEqual([]);
90+
});
91+
});
92+
});

packages/cli/src/utils/lint-flow-patterns.ts

Lines changed: 70 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,32 @@ const DATE_EQ = new RegExp(
4545
);
4646

4747
export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern';
48+
export const FLOW_DOUBLE_BRACE_INTERP = 'flow-double-brace-interpolation';
49+
export const FLOW_BARE_DOLLAR_REF = 'flow-bare-dollar-reference';
50+
51+
// Flow node VALUES interpolate with SINGLE braces (`{var}` / `{rec.field}` /
52+
// `{$User.Id}`). Two wrong-syntax mistakes AI/human authors carry over from the
53+
// *formula* template dialect (`{{ path }}`) or other platforms:
54+
// - `{{ai_reply}}` — double-brace (verified: no flow node uses `{{ }}`).
55+
// - `$source.id` — a `$`-prefixed reference written bare (resolves as a
56+
// literal string), instead of `{source.id}`.
57+
const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/;
58+
// A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged).
59+
// Require a letter/_ after `$` so currency like `$5.00` is never matched.
60+
const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/;
61+
62+
/** Config keys whose string values are CEL predicates, not interpolated templates. */
63+
const CEL_KEYS = new Set(['condition', 'expression', 'conditions']);
64+
65+
/** Collect every interpolated-template string value in a node config (skips CEL keys). */
66+
function collectTemplateStrings(value: unknown, key: string | undefined, out: string[]): void {
67+
if (key && CEL_KEYS.has(key)) return;
68+
if (typeof value === 'string') { out.push(value); return; }
69+
if (Array.isArray(value)) { for (const v of value) collectTemplateStrings(v, key, out); return; }
70+
if (value && typeof value === 'object') {
71+
for (const [k, v] of Object.entries(value as AnyRec)) collectTemplateStrings(v, k, out);
72+
}
73+
}
4874

4975
/**
5076
* Lint every flow's start node for known authoring anti-patterns. Returns a
@@ -55,24 +81,52 @@ export function lintFlowPatterns(stack: AnyRec): FlowLintFinding[] {
5581
for (const flow of asArray(stack.flows)) {
5682
const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)';
5783
const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : [];
84+
85+
// (a) #1874 — date-equality time condition on a record-change start node.
5886
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;
87+
const startCfg = (start?.config ?? {}) as AnyRec;
88+
const triggerType = typeof startCfg.triggerType === 'string' ? startCfg.triggerType : '';
89+
if (triggerType.startsWith('record-')) {
90+
const src = conditionSource(startCfg.condition).trim();
91+
if (src && DATE_EQ.test(src)) {
92+
findings.push({
93+
where: `flow '${flowName}' · start condition`,
94+
message:
95+
`record-change trigger uses a date-EQUALITY time condition (\`${src}\`) — it only fires if the ` +
96+
`record happens to be written on that exact day, so unattended "N days before" rules never run.`,
97+
hint:
98+
`Use a SCHEDULE trigger (daily cron) + a range query instead — e.g. a scheduled flow whose ` +
99+
`get_record filters \`end_date\` BETWEEN {TODAY()} and {TODAY()+N}. (#1874)`,
100+
rule: FLOW_TIME_RELATIVE_ANTIPATTERN,
101+
});
102+
}
103+
}
63104

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-
});
105+
// (b) #1315 — wrong interpolation syntax in any node's template values. Flow
106+
// node values use SINGLE braces; double-brace `{{ }}` and bare `$ref.x`
107+
// are carried over from the formula template dialect / other platforms.
108+
for (const node of nodes) {
109+
const strings: string[] = [];
110+
collectTemplateStrings(node.config, undefined, strings);
111+
const nodeWhere = `flow '${flowName}' · node '${node.id}' (${node.type})`;
112+
for (const str of strings) {
113+
if (DOUBLE_BRACE.test(str)) {
114+
findings.push({
115+
where: nodeWhere,
116+
message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — flow node values use SINGLE braces.`,
117+
hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`,
118+
rule: FLOW_DOUBLE_BRACE_INTERP,
119+
});
120+
}
121+
if (BARE_DOLLAR_REF.test(str)) {
122+
findings.push({
123+
where: nodeWhere,
124+
message: `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` is NOT interpolated.`,
125+
hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`,
126+
rule: FLOW_BARE_DOLLAR_REF,
127+
});
128+
}
129+
}
76130
}
77131
}
78132
return findings;

0 commit comments

Comments
 (0)