From aa76cbab5438d79ac8a19724fea98b67d194b61e Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Mon, 15 Jun 2026 23:18:05 +0800 Subject: [PATCH] feat(cli): lint wrong flow-value interpolation syntax (double-brace / bare $ref) (#1315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/flow-interp-syntax-lint.md | 17 ++++ .../cli/src/utils/lint-flow-patterns.test.ts | 48 ++++++++++- packages/cli/src/utils/lint-flow-patterns.ts | 86 +++++++++++++++---- 3 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 .changeset/flow-interp-syntax-lint.md diff --git a/.changeset/flow-interp-syntax-lint.md b/.changeset/flow-interp-syntax-lint.md new file mode 100644 index 0000000000..e7ff43b271 --- /dev/null +++ b/.changeset/flow-interp-syntax-lint.md @@ -0,0 +1,17 @@ +--- +"@objectstack/cli": patch +--- + +feat(cli): build lint warns on wrong flow-value interpolation syntax (double-brace / bare `$ref`) (#1315) + +Extends the flow authoring anti-pattern lint with two advisory WARNINGs for the +interpolation-syntax mistakes AI/human authors carry over from other dialects: + +- **double-brace** `{{ai_reply}}` in a flow node value — flow node values use + SINGLE braces (`{var}`); `{{ }}` is the formula/template-field dialect, never + flow node values (verified: no flow node executor uses `{{ }}`). +- **bare `$ref.field`** (e.g. `$source.id`) written as a plain value — it's not + interpolated; the author meant `{source.id}` (or `{$User.Id}`). + +Precise: single-brace interpolation, braced `{$User.Id}`, currency literals +(`$5.00`), and CEL condition fields are NOT flagged; never fails the build. diff --git a/packages/cli/src/utils/lint-flow-patterns.test.ts b/packages/cli/src/utils/lint-flow-patterns.test.ts index 27b9a334a2..93d54a6754 100644 --- a/packages/cli/src/utils/lint-flow-patterns.test.ts +++ b/packages/cli/src/utils/lint-flow-patterns.test.ts @@ -1,7 +1,12 @@ // 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'; +import { + lintFlowPatterns, + FLOW_TIME_RELATIVE_ANTIPATTERN, + FLOW_DOUBLE_BRACE_INTERP, + FLOW_BARE_DOLLAR_REF, +} from './lint-flow-patterns.js'; const flow = (condition: unknown, triggerType = 'record-after-update') => ({ flows: [{ @@ -44,3 +49,44 @@ describe('lintFlowPatterns — time-relative anti-pattern (#1874)', () => { }); }); }); + +/** A flow with a create_record node carrying `config`. */ +const nodeFlow = (config: Record) => ({ + flows: [{ + name: 'mk', + nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'create', type: 'create_record', config }, + ], + edges: [], + }], +}); +const rules = (s: any) => lintFlowPatterns(s).map((f) => f.rule); + +describe('lintFlowPatterns — wrong interpolation syntax (#1315)', () => { + it('flags double-brace interpolation in a node value', () => { + expect(rules(nodeFlow({ objectName: 'm', fields: { body: '{{ai_reply}}' } }))) + .toContain(FLOW_DOUBLE_BRACE_INTERP); + }); + it('flags a bare $ref.field written as a literal', () => { + expect(rules(nodeFlow({ objectName: 'm', fields: { ticket: '$source.id' } }))) + .toContain(FLOW_BARE_DOLLAR_REF); + }); + describe('does NOT flag (false-positive guards)', () => { + it('correct single-brace interpolation', () => { + expect(rules(nodeFlow({ objectName: 'm', fields: { body: '{ai_reply}', t: 'Hi {record.name}' } }))).toEqual([]); + }); + it('a braced $User reference', () => { + expect(rules(nodeFlow({ objectName: 'm', fields: { owner: '{$User.Id}' } }))).toEqual([]); + }); + it('a currency literal', () => { + expect(rules(nodeFlow({ objectName: 'm', fields: { price: '$5.00', label: 'Total $5' } }))).toEqual([]); + }); + it('a CEL condition (skipped — not a template value)', () => { + expect(rules({ flows: [{ name: 'd', nodes: [ + { id: 'start', type: 'start', config: {} }, + { id: 'dec', type: 'decision', config: { condition: 'record.amount > 100' } }, + ], edges: [] }] })).toEqual([]); + }); + }); +}); diff --git a/packages/cli/src/utils/lint-flow-patterns.ts b/packages/cli/src/utils/lint-flow-patterns.ts index 43e3199054..5899e084f5 100644 --- a/packages/cli/src/utils/lint-flow-patterns.ts +++ b/packages/cli/src/utils/lint-flow-patterns.ts @@ -45,6 +45,32 @@ const DATE_EQ = new RegExp( ); export const FLOW_TIME_RELATIVE_ANTIPATTERN = 'flow-time-relative-antipattern'; +export const FLOW_DOUBLE_BRACE_INTERP = 'flow-double-brace-interpolation'; +export const FLOW_BARE_DOLLAR_REF = 'flow-bare-dollar-reference'; + +// 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: +// - `{{ai_reply}}` — double-brace (verified: no flow node uses `{{ }}`). +// - `$source.id` — a `$`-prefixed reference written bare (resolves as a +// literal string), instead of `{source.id}`. +const DOUBLE_BRACE = /\{\{\s*[\w$][\w$.\s]*\}\}/; +// A `$Ident.field` not immediately inside a `{` (so `{$User.Id}` is NOT flagged). +// Require a letter/_ after `$` so currency like `$5.00` is never matched. +const BARE_DOLLAR_REF = /(?:^|[^{])\$[A-Za-z_]\w*\.[A-Za-z_]/; + +/** Config keys whose string values are CEL predicates, not interpolated templates. */ +const CEL_KEYS = new Set(['condition', 'expression', 'conditions']); + +/** Collect every interpolated-template string value in a node config (skips CEL keys). */ +function collectTemplateStrings(value: unknown, key: string | undefined, out: string[]): void { + if (key && CEL_KEYS.has(key)) return; + if (typeof value === 'string') { out.push(value); return; } + if (Array.isArray(value)) { for (const v of value) collectTemplateStrings(v, key, out); return; } + if (value && typeof value === 'object') { + for (const [k, v] of Object.entries(value as AnyRec)) collectTemplateStrings(v, k, out); + } +} /** * Lint every flow's start node for known authoring anti-patterns. Returns a @@ -55,24 +81,52 @@ export function lintFlowPatterns(stack: AnyRec): 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[]) : []; + + // (a) #1874 — date-equality time condition on a record-change start node. 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 startCfg = (start?.config ?? {}) as AnyRec; + const triggerType = typeof startCfg.triggerType === 'string' ? startCfg.triggerType : ''; + if (triggerType.startsWith('record-')) { + const src = conditionSource(startCfg.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, + }); + } + } - 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, - }); + // (b) #1315 — wrong interpolation syntax in any node's template values. Flow + // 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 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({ + where: nodeWhere, + message: `double-brace interpolation \`${str.trim().slice(0, 80)}\` — flow node values use SINGLE braces.`, + hint: `Use \`{var}\` (e.g. \`{record.title}\`). Double-brace \`{{ }}\` is the formula/template-field dialect, not flow node values. (#1315)`, + rule: FLOW_DOUBLE_BRACE_INTERP, + }); + } + if (BARE_DOLLAR_REF.test(str)) { + findings.push({ + where: nodeWhere, + message: `\`${str.trim().slice(0, 80)}\` looks like a reference written as a literal — a bare \`$ref.field\` is NOT interpolated.`, + hint: `Wrap it and bind a variable: \`{source.id}\` (or \`{$User.Id}\` for the current user). (#1315)`, + rule: FLOW_BARE_DOLLAR_REF, + }); + } + } } } return findings;