Skip to content

Commit 78957f8

Browse files
committed
feat(lint)!: refuse a flow CEL root the flow does not bind (wip)
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 99801d8 commit 78957f8

2 files changed

Lines changed: 431 additions & 4 deletions

File tree

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* @module flow-cel-root-scope
5+
*
6+
* **The build door's judge of the ROOTS a flow CEL expression reads.** A flow
7+
* expression whose root identifier the run does not bind fails that run with
8+
* `Unknown variable: X` — measured on the flow scope the engine builds
9+
* (`AutomationEngine.celScope` over `seedRunVariables`' map): `user.id == "u1"`,
10+
* `ctx.user.id == "u1"`, `os.user.id == "u1"` and `foo.bar == 1` each fault, while
11+
* `objectstack validate` answered nothing for any of them. The three user
12+
* spellings are the trap: formulas, row-level security and the client bind the
13+
* run's user under them (`buildScope` in `@objectstack/formula`), and flow CEL
14+
* binds it as `current_user` only, so an author who writes the alias by habit
15+
* ships a flow that faults. This module refuses such a root at build, naming
16+
* `current_user` for the user spellings and the in-scope names for any other.
17+
*
18+
* ## One judge, every flow CEL site
19+
*
20+
* The caller (`validateStackExpressions`) hands it each flow CEL source it
21+
* already visits: a node's `config.condition` (the start node's is the trigger
22+
* gate), an edge's `condition`, a `decision` branch's `expression`, a `screen`
23+
* field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a
24+
* `create_record` / `update_record` `fields` map. All of them evaluate in the
25+
* one scope `celScope` builds, so one bound set per flow judges them all.
26+
*
27+
* ## The bound set — the flow's own static bindings
28+
*
29+
* - **the engine's roots**: `record` and `previous` (`seedRunVariables` binds
30+
* `previous` on every run, `null` on a create; `celScope` binds `record` to
31+
* the trigger record, or to the variables themselves when there is none),
32+
* `vars` and `current_user` (`celScope` binds both after the spread). A
33+
* `$`-named variable has no CEL spelling at all — `$runId == "x"` does not
34+
* parse — so the `$` names never reach this judge: they stay the text-slot
35+
* rule's (`flow-text-slot-template.ts`), and this module keeps no list of them;
36+
* - **every name the flow binds**: {@link collectFlowVariableNames} — declared
37+
* variables, `outputVariable` / `errorVariable` / `iteratorVariable` /
38+
* `indexVariable`, `assignment` targets, node ids — plus the two bindings that
39+
* reader does not collect: a `screen` field's `name` (a resume lands the
40+
* collected values under their plain names) and a `loop`'s default iterator
41+
* `item` when the node names none;
42+
* - **record fields**: the trigger record's fields are flattened to top-level
43+
* names. Every field of EVERY object the stack declares counts, with the
44+
* registry-injected columns — not only the trigger object's — because the run
45+
* flattens whichever record its entrance hands it: a flow action passes the
46+
* action's own record to any flow it starts, a `subflow` child inherits its
47+
* parent's record, and a `map` child gets each record item. A field the stack
48+
* declares anywhere is therefore a root this door cannot prove unbound.
49+
*
50+
* ## When the door cannot prove a root unbound, it stands down
51+
*
52+
* A false refusal of a working flow is worse than the trap, so the whole flow is
53+
* left unjudged when its run can bind a name the reader cannot see:
54+
*
55+
* - a node whose type is not one of {@link CLOSED_BINDER_NODE_TYPES}. The node
56+
* type namespace is open (ADR-0018), and the executors that hand out the LIVE
57+
* variable map are open by measurement: `script` (a registered function gets
58+
* `FlowFunctionContext.variables`) and `connector_action`
59+
* (`ConnectorActionContext.variables`) may write any name; `wait`, `subflow`
60+
* and `map` declare `resumeAuthority: 'any'`, and a resume folds the caller's
61+
* `variables` bag in under its plain names (`applyResumeSignal`);
62+
* - a `screen` with no input contract — an `object-form` screen, or one that
63+
* declares no fields — because `refuseInvalidScreenInput` checks a resume bag
64+
* only against a declared field list, so a contract-less screen folds any bag;
65+
* - a trigger object the stack does not declare (a platform object, another
66+
* package's): its row's keys are not in hand.
67+
*
68+
* ⚠️ The boundary that remains: a record of an object this stack does not
69+
* declare, handed in by a flow action, a parent `subflow` or a `map` item, can
70+
* carry a key the reader has never seen. Measured over the example apps and
71+
* hotcrm, no flow reads a bare root that is not its own binding, an engine root
72+
* or a field of its trigger object.
73+
*/
74+
75+
import { firstUndeclaredReference, parseCelToAst, SCOPE_ROOTS } from '@objectstack/formula';
76+
import {
77+
APPROVAL_NODE_TYPE,
78+
APPROVAL_REVISE_NODE_TYPE,
79+
LOOP_NODE_TYPE,
80+
PARALLEL_NODE_TYPE,
81+
TRY_CATCH_NODE_TYPE,
82+
} from '@objectstack/spec/automation';
83+
84+
import { collectFlowVariableNames, type FlowGraphLike, type FlowVariableHost } from './flow-variable-scope.js';
85+
import { nearestName, recordsOf } from './object-graph.js';
86+
87+
type AnyRec = Record<string, unknown>;
88+
89+
/**
90+
* The roots the flow engine binds on every run, whatever the flow declares:
91+
* `record` and `previous` (`seedRunVariables`, and `celScope`'s `record` arm),
92+
* `vars` and `current_user` (bound by `celScope` after the variables are spread).
93+
* Not the `$` names — those have no CEL spelling (module note).
94+
*/
95+
const ENGINE_BOUND_ROOTS: readonly string[] = ['record', 'previous', 'vars', 'current_user'];
96+
97+
/**
98+
* The node types whose variable writes this reader models completely — every
99+
* name each one can bind is a key of its own `config` that
100+
* {@link collectFlowVariableNames} or {@link flowCelRootScope} reads, or its node
101+
* id. Measured from the executors:
102+
*
103+
* | type | binds |
104+
* |---|---|
105+
* | `start`, `end`, `decision`, `http`, `notify`, `delete_record` | nothing but its node id's outputs |
106+
* | `assignment` | its targets |
107+
* | `create_record`, `update_record`, `get_record` | `outputVariable` |
108+
* | `loop` | `iteratorVariable` (default `item`), `indexVariable` |
109+
* | `parallel` | nothing; its branches are graphs of their own |
110+
* | `try_catch` | `errorVariable` (default `$error`) |
111+
* | `screen` with an input contract | its fields' names, `outputVariable` |
112+
* | `approval`, `approval_revise` | nothing; their service resumes with outputs only (`resumeAuthority: 'service'`) |
113+
*
114+
* Every other type — a plugin's, and `script`, `connector_action`, `wait`,
115+
* `subflow`, `map` (module note) — makes the bound set unprovable.
116+
*/
117+
const CLOSED_BINDER_NODE_TYPES: ReadonlySet<string> = new Set([
118+
'start',
119+
'end',
120+
'decision',
121+
'assignment',
122+
'create_record',
123+
'update_record',
124+
'delete_record',
125+
'get_record',
126+
'http',
127+
'notify',
128+
'screen',
129+
LOOP_NODE_TYPE,
130+
PARALLEL_NODE_TYPE,
131+
TRY_CATCH_NODE_TYPE,
132+
APPROVAL_NODE_TYPE,
133+
APPROVAL_REVISE_NODE_TYPE,
134+
]);
135+
136+
/** The run-user spellings formulas, RLS and the client accept and flow CEL does not bind. */
137+
const RUN_USER_ALIAS_ROOTS: ReadonlySet<string> = new Set(['user', 'ctx', 'os']);
138+
139+
/** What the door knows about one flow's CEL scope. */
140+
export interface FlowCelRootScope {
141+
/**
142+
* Every root the run can bind, in the order a did-you-mean prefers them: the
143+
* trigger object's fields first (a flattened condition's commonest typo), then
144+
* the flow's own bindings, the engine's roots, and the other objects' fields.
145+
* Meaningful only when {@link provable}.
146+
*/
147+
readonly bound: ReadonlySet<string>;
148+
/**
149+
* `false` ⇒ the run can bind a name the reader cannot see, and no root of this
150+
* flow is judged. {@link openedBy} says why.
151+
*/
152+
readonly provable: boolean;
153+
/** The first reason the bound set is not provable, for tests and diagnostics. */
154+
readonly openedBy?: string;
155+
}
156+
157+
/** The flow-level slice the scope reads. */
158+
export interface FlowCelRootHost extends FlowVariableHost {
159+
readonly nodes?: unknown;
160+
}
161+
162+
/** One root a flow CEL source reads that the flow does not bind. */
163+
export interface UnboundFlowCelRoot {
164+
/** The root identifier (`user`). */
165+
readonly root: string;
166+
/** The member names read directly off it (`user.id` → `id`), in source order. */
167+
readonly members: readonly string[];
168+
}
169+
170+
/**
171+
* The bound set of one flow, or the reason it cannot be proved.
172+
*
173+
* @param graphs every graph of the flow (`collectFlowGraphs`), so a region's
174+
* nodes count — one variable map serves the whole run.
175+
* @param triggerObject the start node's `objectName`, when it names one.
176+
* @param fieldIndex object name → its field names, injected columns included.
177+
*/
178+
export function flowCelRootScope(
179+
flow: FlowCelRootHost,
180+
graphs: readonly FlowGraphLike[],
181+
triggerObject: string | undefined,
182+
fieldIndex: ReadonlyMap<string, readonly string[]>,
183+
): FlowCelRootScope {
184+
const bound = new Set<string>(triggerObject !== undefined ? fieldIndex.get(triggerObject) ?? [] : []);
185+
for (const name of collectFlowVariableNames(flow, graphs)) bound.add(name);
186+
for (const name of ENGINE_BOUND_ROOTS) bound.add(name);
187+
let openedBy: string | undefined;
188+
const open = (reason: string): void => { openedBy ??= reason; };
189+
190+
for (const graph of graphs) {
191+
for (const node of recordsOf(graph.nodes)) {
192+
const type = typeof node.type === 'string' ? node.type : '';
193+
const id = typeof node.id === 'string' ? node.id : '?';
194+
if (!CLOSED_BINDER_NODE_TYPES.has(type)) {
195+
open(`node '${id}' (${type || 'no type'}) can bind names the reader cannot see`);
196+
continue;
197+
}
198+
const config = (node.config && typeof node.config === 'object' && !Array.isArray(node.config)
199+
? node.config
200+
: {}) as AnyRec;
201+
if (type === LOOP_NODE_TYPE && !(typeof config.iteratorVariable === 'string' && config.iteratorVariable)) {
202+
// `LoopConfigSchema` defaults the iterator to `item`; a config read before
203+
// that parse carries no key, and the run still binds the default.
204+
bound.add('item');
205+
}
206+
if (type === 'screen') {
207+
const fields = Array.isArray(config.fields) ? recordsOf(config.fields) : [];
208+
if (config.kind === 'object-form' || fields.length === 0) {
209+
open(`screen '${id}' declares no input contract, so a resume can bind any name`);
210+
continue;
211+
}
212+
for (const field of fields) {
213+
if (typeof field.name === 'string' && field.name) bound.add(field.name);
214+
}
215+
}
216+
}
217+
}
218+
219+
if (triggerObject !== undefined && !fieldIndex.has(triggerObject)) {
220+
open(`trigger object '${triggerObject}' is not declared in this stack, so its fields are not in hand`);
221+
}
222+
for (const names of fieldIndex.values()) for (const name of names) bound.add(name);
223+
224+
return openedBy === undefined ? { bound, provable: true } : { bound, provable: false, openedBy };
225+
}
226+
227+
/** cel-js's comprehension macros: the receiver calls that bind their first argument in the rest. */
228+
const COMPREHENSION_MACROS: ReadonlySet<string> = new Set(['all', 'exists', 'exists_one', 'map', 'filter']);
229+
230+
interface CelNode {
231+
readonly op: string;
232+
readonly args: unknown;
233+
}
234+
235+
function isCelNode(value: unknown): value is CelNode {
236+
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof (value as AnyRec).op === 'string';
237+
}
238+
239+
function boundIdentifier(value: unknown): string | undefined {
240+
return isCelNode(value) && value.op === 'id' && typeof value.args === 'string' ? value.args : undefined;
241+
}
242+
243+
/**
244+
* The FREE root identifiers of a parsed CEL source, with the members read off
245+
* each. A name a comprehension macro (`all`, `exists`, `exists_one`, `map`,
246+
* `filter`) or `cel.bind` binds is free only outside the part it is bound in —
247+
* the same scoping cel-js applies (`lib/macros.js`).
248+
*/
249+
function freeRoots(ast: unknown): Map<string, string[]> {
250+
const out = new Map<string, string[]>();
251+
const note = (name: string, member: string | undefined): void => {
252+
const members = out.get(name) ?? [];
253+
if (member !== undefined && !members.includes(member)) members.push(member);
254+
out.set(name, members);
255+
};
256+
const walk = (node: unknown, scoped: ReadonlySet<string>, member?: string): void => {
257+
if (Array.isArray(node)) {
258+
for (const child of node) walk(child, scoped);
259+
return;
260+
}
261+
if (!isCelNode(node)) return;
262+
const args = node.args;
263+
if (node.op === 'id') {
264+
if (typeof args === 'string' && !scoped.has(args)) note(args, member);
265+
return;
266+
}
267+
if ((node.op === '.' || node.op === '.?') && Array.isArray(args)) {
268+
walk(args[0], scoped, typeof args[1] === 'string' ? args[1] : undefined);
269+
return;
270+
}
271+
if (node.op === 'rcall' && Array.isArray(args)) {
272+
const [name, receiver, list] = args as [unknown, unknown, unknown];
273+
const variable = Array.isArray(list) ? boundIdentifier(list[0]) : undefined;
274+
if (typeof name === 'string' && COMPREHENSION_MACROS.has(name) && Array.isArray(list) && list.length >= 2 && variable) {
275+
walk(receiver, scoped);
276+
walk(list.slice(1), new Set([...scoped, variable]));
277+
return;
278+
}
279+
if (name === 'bind' && boundIdentifier(receiver) === 'cel' && Array.isArray(list) && list.length === 3 && variable) {
280+
walk(list[1], scoped);
281+
walk(list[2], new Set([...scoped, variable]));
282+
return;
283+
}
284+
walk(receiver, scoped);
285+
walk(list, scoped);
286+
return;
287+
}
288+
if (node.op === 'value') return;
289+
walk(args, scoped);
290+
};
291+
walk(ast, new Set());
292+
return out;
293+
}
294+
295+
/**
296+
* Whether `name`, read as a bare identifier, is a VARIABLE the run must bind —
297+
* as opposed to a name CEL declares itself (a type such as `int`, the `cel`
298+
* namespace). Answered by the strict checker, which knows every such name; a
299+
* platform scope root (`user`, `ctx`, `os`, `data`, …) is declared there as a
300+
* convenience for other surfaces, and is a variable here.
301+
*/
302+
function isVariableReference(name: string): boolean {
303+
if ((SCOPE_ROOTS as readonly string[]).includes(name)) return true;
304+
return firstUndeclaredReference(name) === name;
305+
}
306+
307+
/**
308+
* The roots `source` reads that `scope` does not bind, in source order — empty
309+
* when the scope is not provable, or when the source does not parse (the syntax
310+
* check owns that verdict: one broken expression, one finding).
311+
*/
312+
export function unboundFlowCelRoots(source: string, scope: FlowCelRootScope): UnboundFlowCelRoot[] {
313+
if (!scope.provable) return [];
314+
const ast = parseCelToAst(source);
315+
if (!ast) return [];
316+
const out: UnboundFlowCelRoot[] = [];
317+
for (const [root, members] of freeRoots(ast)) {
318+
if (scope.bound.has(root) || !isVariableReference(root)) continue;
319+
out.push({ root, members });
320+
}
321+
return out;
322+
}
323+
324+
/**
325+
* The refusal for one unbound root.
326+
*
327+
* A run-user spelling (`user`, `ctx.user`, `os.user`) is told to write
328+
* `current_user`; any other root is named with what the flow could have bound
329+
* it, and the nearest in-scope name when there is one.
330+
*/
331+
export function unboundFlowCelRootMessage(unbound: UnboundFlowCelRoot, scope: FlowCelRootScope): string {
332+
const { root, members } = unbound;
333+
const userSpelling = root === 'user' ? 'user' : RUN_USER_ALIAS_ROOTS.has(root) && members.includes('user') ? `${root}.user` : undefined;
334+
if (userSpelling !== undefined) {
335+
return (
336+
`\`${userSpelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\`: ` +
337+
'a flow binds the run\'s user as `current_user` only — the `user`, `ctx.user` and `os.user` spellings that ' +
338+
'formulas, row-level security and the client accept are not bound here. ' +
339+
`Write \`current_user\` in its place (\`${userSpelling}.id\` → \`current_user.id\`); for a flow that can run ` +
340+
'without a user, guard it: `current_user != null ? current_user.id : null`.'
341+
);
342+
}
343+
const near = nearestName(root, scope.bound);
344+
return (
345+
`\`${root}\` is not bound in this flow's expression scope, so the run fails with \`Unknown variable: ${root}\`: ` +
346+
'no variable, `outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field ' +
347+
`of this flow is named \`${root}\`, no object in the stack declares a field \`${root}\`, and the engine binds ` +
348+
'only `record`, `previous`, `vars` and `current_user`. ' +
349+
`Declare \`${root}\` as a flow variable or bind it with a node, or correct the name` +
350+
(near ? ` — did you mean \`${near}\`?` : '.')
351+
);
352+
}

0 commit comments

Comments
 (0)