|
18 | 18 | */ |
19 | 19 |
|
20 | 20 | import { validateExpression } from '@objectstack/formula'; |
21 | | -import { resolveFlowNodeExpressions } from '@objectstack/spec/automation'; |
| 21 | +import { collectFlowGraphs, resolveFlowNodeExpressions } from '@objectstack/spec/automation'; |
| 22 | +import type { FlowNodeParsed } from '@objectstack/spec/automation'; |
22 | 23 |
|
23 | 24 | export interface ExprIssue { |
24 | 25 | where: string; |
@@ -130,76 +131,85 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] { |
130 | 131 | for (const flow of asArray(stack.flows)) { |
131 | 132 | const flowName = typeof flow.name === 'string' ? flow.name : '(unnamed flow)'; |
132 | 133 | const nodes = Array.isArray(flow.nodes) ? (flow.nodes as AnyRec[]) : []; |
133 | | - const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : []; |
134 | 134 | // The record-change target object — `record.*` refs resolve against it. |
135 | 135 | const startNode = nodes.find(n => n.type === 'start'); |
136 | 136 | const startCfg = (startNode?.config ?? {}) as AnyRec; |
137 | 137 | const objectName = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined; |
138 | 138 |
|
139 | | - for (const node of nodes) { |
140 | | - const cfg = (node.config ?? {}) as AnyRec; |
141 | | - check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName); |
| 139 | + // #4347 — every graph in the flow, not just `flow.nodes`/`flow.edges`. An |
| 140 | + // ADR-0031 container keeps a whole sub-graph in its `config`, so the |
| 141 | + // top-level walk validated PART of the flow while reporting on all of it: a |
| 142 | + // predicate written in the wrong dialect inside a `loop` body passed |
| 143 | + // `objectstack validate` and shipped. This is the author-time half of the |
| 144 | + // same traversal the engine's registration pass now does; `scope` names the |
| 145 | + // region so the located message still points at one edge. |
| 146 | + for (const graph of collectFlowGraphs(flow as { nodes?: FlowNodeParsed[] })) { |
| 147 | + const at = graph.scope ? `flow '${flowName}' · ${graph.scope}` : `flow '${flowName}'`; |
| 148 | + for (const node of graph.nodes as unknown as AnyRec[]) { |
| 149 | + const cfg = (node.config ?? {}) as AnyRec; |
| 150 | + check(`${at} · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName); |
142 | 151 |
|
143 | | - // Descriptor-declared expression slots (#4027). Before this, the traversal |
144 | | - // hardcoded `condition` and assumed every other node string was a `{var}` |
145 | | - // template — so `screen.fields[].visibleWhen`, declared bare CEL since |
146 | | - // #3304, was validated by nobody and #3528 shipped a template-dialect |
147 | | - // predicate through compile, validate and run time in silence. |
148 | | - // Only `predicate` slots are checkable: `flow-template` slots take the |
149 | | - // single-brace `{var}` dialect `interpolate()` implements, which no |
150 | | - // validator covers (the `template` role enforces ADR-0032 §3's |
151 | | - // double-brace text template and would reject every correct |
152 | | - // `loop.collection`). The ledger records them regardless, so the |
153 | | - // reconciliation ratchet still sees the marker. |
154 | | - const nodeType = typeof node.type === 'string' ? node.type : ''; |
155 | | - for (const found of resolveFlowNodeExpressions(nodeType, cfg)) { |
156 | | - if (found.entry.role !== 'predicate') continue; |
157 | | - checkDeclaredPredicate( |
158 | | - `flow '${flowName}' · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`, |
159 | | - found.value, |
160 | | - ); |
161 | | - } |
162 | | - // #1870 — a `script` node must declare a callable target (`actionType` or |
163 | | - // `function`). A node with neither is a silent no-op that otherwise passes |
164 | | - // build. (Function *existence* isn't checkable here — functions are code, |
165 | | - // not serialized into the artifact — so this is a structural check; the |
166 | | - // runtime verifies the named function is actually registered.) |
167 | | - if (node.type === 'script') { |
168 | | - // `function` is canonical; a pre-parse source may still carry the |
169 | | - // `functionName` alias during the protocol-17 window, until the |
170 | | - // 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it. |
171 | | - const fn = |
172 | | - (typeof cfg.function === 'string' ? cfg.function.trim() : '') || |
173 | | - (typeof cfg.functionName === 'string' ? cfg.functionName.trim() : ''); |
174 | | - const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : ''; |
175 | | - // Inline `config.script` (a JS body) is also a declared form — the |
176 | | - // built-in runtime doesn't execute it (warned at run time), but the node |
177 | | - // is not the empty no-op this check targets, so don't flag it. |
178 | | - const inline = typeof cfg.script === 'string' ? cfg.script.trim() : ''; |
179 | | - if (!fn && !action && !inline) { |
180 | | - issues.push({ |
181 | | - where: `flow '${flowName}' · node '${node.id}' (script) callable`, |
182 | | - message: |
183 | | - `script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` + |
184 | | - `Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` + |
185 | | - `(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`, |
186 | | - source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), |
187 | | - }); |
188 | | - } else if (action === 'invoke_function' && !fn) { |
189 | | - // `actionType: 'invoke_function'` is a marker that names no callable on |
190 | | - // its own — the function name must be in `function`/`functionName`. |
191 | | - issues.push({ |
192 | | - where: `flow '${flowName}' · node '${node.id}' (script) callable`, |
193 | | - message: |
194 | | - `script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` + |
195 | | - `it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`, |
196 | | - source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), |
197 | | - }); |
| 152 | + // Descriptor-declared expression slots (#4027). Before this, the traversal |
| 153 | + // hardcoded `condition` and assumed every other node string was a `{var}` |
| 154 | + // template — so `screen.fields[].visibleWhen`, declared bare CEL since |
| 155 | + // #3304, was validated by nobody and #3528 shipped a template-dialect |
| 156 | + // predicate through compile, validate and run time in silence. |
| 157 | + // Only `predicate` slots are checkable: `flow-template` slots take the |
| 158 | + // single-brace `{var}` dialect `interpolate()` implements, which no |
| 159 | + // validator covers (the `template` role enforces ADR-0032 §3's |
| 160 | + // double-brace text template and would reject every correct |
| 161 | + // `loop.collection`). The ledger records them regardless, so the |
| 162 | + // reconciliation ratchet still sees the marker. |
| 163 | + const nodeType = typeof node.type === 'string' ? node.type : ''; |
| 164 | + for (const found of resolveFlowNodeExpressions(nodeType, cfg)) { |
| 165 | + if (found.entry.role !== 'predicate') continue; |
| 166 | + checkDeclaredPredicate( |
| 167 | + `${at} · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`, |
| 168 | + found.value, |
| 169 | + ); |
| 170 | + } |
| 171 | + // #1870 — a `script` node must declare a callable target (`actionType` or |
| 172 | + // `function`). A node with neither is a silent no-op that otherwise passes |
| 173 | + // build. (Function *existence* isn't checkable here — functions are code, |
| 174 | + // not serialized into the artifact — so this is a structural check; the |
| 175 | + // runtime verifies the named function is actually registered.) |
| 176 | + if (node.type === 'script') { |
| 177 | + // `function` is canonical; a pre-parse source may still carry the |
| 178 | + // `functionName` alias during the protocol-17 window, until the |
| 179 | + // 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it. |
| 180 | + const fn = |
| 181 | + (typeof cfg.function === 'string' ? cfg.function.trim() : '') || |
| 182 | + (typeof cfg.functionName === 'string' ? cfg.functionName.trim() : ''); |
| 183 | + const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : ''; |
| 184 | + // Inline `config.script` (a JS body) is also a declared form — the |
| 185 | + // built-in runtime doesn't execute it (warned at run time), but the node |
| 186 | + // is not the empty no-op this check targets, so don't flag it. |
| 187 | + const inline = typeof cfg.script === 'string' ? cfg.script.trim() : ''; |
| 188 | + if (!fn && !action && !inline) { |
| 189 | + issues.push({ |
| 190 | + where: `${at} · node '${node.id}' (script) callable`, |
| 191 | + message: |
| 192 | + `script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` + |
| 193 | + `Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` + |
| 194 | + `(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`, |
| 195 | + source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), |
| 196 | + }); |
| 197 | + } else if (action === 'invoke_function' && !fn) { |
| 198 | + // `actionType: 'invoke_function'` is a marker that names no callable on |
| 199 | + // its own — the function name must be in `function`/`functionName`. |
| 200 | + issues.push({ |
| 201 | + where: `${at} · node '${node.id}' (script) callable`, |
| 202 | + message: |
| 203 | + `script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` + |
| 204 | + `it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`, |
| 205 | + source: JSON.stringify({ id: node.id, type: node.type, config: cfg }), |
| 206 | + }); |
| 207 | + } |
198 | 208 | } |
199 | 209 | } |
200 | | - } |
201 | | - for (const edge of edges) { |
202 | | - check(`flow '${flowName}' · edge '${edge.id}' (${edge.source}→${edge.target}) condition`, edge.condition, objectName); |
| 210 | + for (const edge of graph.edges as unknown as AnyRec[]) { |
| 211 | + check(`${at} · edge '${edge.id}' (${edge.source}→${edge.target}) condition`, edge.condition, objectName); |
| 212 | + } |
203 | 213 | } |
204 | 214 | } |
205 | 215 |
|
|
0 commit comments