Skip to content

Commit 69b0c3f

Browse files
committed
test(lint): pin the flow CEL root judge; declare the bindings fixtures read
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78957f8 commit 69b0c3f

4 files changed

Lines changed: 309 additions & 5 deletions

File tree

‎packages/lint/src/lint-flow-patterns.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,6 +2612,11 @@ describe('#16405 — an `http` node payload is not a region, and both #1315 rule
26122612
return {
26132613
flows: [{
26142614
name: 'stamp_owner', label: 'Stamp owner', type: 'autolaunched',
2615+
// The variables the prescriptions below read, bound as their hints
2616+
// say they must be: the flow's own `source` (`$source`'s remedy) and
2617+
// the `me` a `get_record` binds (`$User.Email`'s). The build door
2618+
// refuses a root the flow does not bind (#22565), as the run does.
2619+
variables: [{ name: 'source', type: 'object' }, { name: 'me', type: 'object' }],
26152620
nodes: [
26162621
{ id: 'start', type: 'start', label: 'Start' },
26172622
{ id: 'write', type, label: 'Write', config },
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #22565 — the build door refuses a flow CEL root the flow does not bind.
5+
*
6+
* Measured on the flow scope the engine builds (`AutomationEngine.celScope`
7+
* over `seedRunVariables`' map): `user.id == "u1"`, `ctx.user.id == "u1"`,
8+
* `os.user.id == "u1"` and `foo.bar == 1` each fail the run with
9+
* `Unknown variable: <root>`, while `validateStackExpressions` answered 0 issues
10+
* for every one of them. The three user spellings are the ones formulas, RLS and
11+
* the client bind; flow CEL binds the run's user as `current_user` only.
12+
*
13+
* The judge lives in `flow-cel-root-scope.ts`; these pins drive it through the
14+
* door `objectstack validate` and the runtime publish gate both call.
15+
*/
16+
17+
import { describe, expect, it } from 'vitest';
18+
19+
import { flowCelRootScope, unboundFlowCelRoots } from './flow-cel-root-scope.js';
20+
import { validateStackExpressions } from './validate-expressions.js';
21+
22+
type AnyRec = Record<string, unknown>;
23+
24+
const OBJECTS = [{ name: 'acct', fields: { status: { type: 'text' }, amount: { type: 'number' } } }];
25+
26+
/** start → (edge carrying `edgeCondition`) → done, plus any `middle` nodes, on an optional trigger object. */
27+
function flowStack(opts: {
28+
edgeCondition?: unknown;
29+
startCondition?: unknown;
30+
variables?: AnyRec[];
31+
middle?: AnyRec[];
32+
objectName?: string;
33+
objects?: AnyRec[];
34+
}): AnyRec {
35+
return {
36+
objects: opts.objects ?? OBJECTS,
37+
flows: [{
38+
name: 'f',
39+
label: 'F',
40+
type: 'autolaunched',
41+
...(opts.variables ? { variables: opts.variables } : {}),
42+
nodes: [
43+
{
44+
id: 'start',
45+
type: 'start',
46+
config: {
47+
...(opts.objectName ? { objectName: opts.objectName } : {}),
48+
...(opts.startCondition !== undefined ? { condition: opts.startCondition } : {}),
49+
},
50+
},
51+
...(opts.middle ?? []),
52+
{ id: 'done', type: 'end', config: {} },
53+
],
54+
edges: [
55+
{ id: 'e1', source: 'start', target: 'done', ...(opts.edgeCondition !== undefined ? { condition: opts.edgeCondition } : {}) },
56+
],
57+
}],
58+
};
59+
}
60+
61+
const errorsOf = (stack: AnyRec) => validateStackExpressions(stack).filter((i) => (i.severity ?? 'error') === 'error');
62+
/**
63+
* The judge's own findings — every refusal it writes says the root "is not bound
64+
* in" a flow scope. Controls and stand-downs read these, so a node-config refusal
65+
* about an unrelated key can neither mask nor fake a verdict here.
66+
*/
67+
const rootFindings = (stack: AnyRec) => errorsOf(stack).filter((i) => / is not bound in (a flow expression|this flow's expression scope)/.test(i.message));
68+
const CURRENT_USER_REMEDY = 'a flow binds the run\'s user as `current_user` only';
69+
70+
describe('the triage pins (#22565)', () => {
71+
it('an edge condition `user.id == "u1"` is refused with the `current_user` remedy', () => {
72+
const found = errorsOf(flowStack({ edgeCondition: 'user.id == "u1"' }));
73+
expect(found).toHaveLength(1);
74+
expect(found[0]!.where).toBe("flow 'f' · edge 'e1' (start→done) condition");
75+
expect(found[0]!.source).toBe('user.id == "u1"');
76+
expect(found[0]!.message).toMatch(/^`user` is not bound in a flow expression, so the run fails with `Unknown variable: user`/);
77+
expect(found[0]!.message).toContain(CURRENT_USER_REMEDY);
78+
expect(found[0]!.message).toContain('(`user.id` → `current_user.id`)');
79+
});
80+
81+
it('control: the same condition on a flow that declares a variable `user` passes', () => {
82+
expect(validateStackExpressions(flowStack({
83+
edgeCondition: 'user.id == "u1"',
84+
variables: [{ name: 'user', type: 'object' }],
85+
}))).toEqual([]);
86+
});
87+
88+
it('control: the remedy itself — `current_user.id == "u1"` — passes', () => {
89+
expect(validateStackExpressions(flowStack({ edgeCondition: 'current_user.id == "u1"' }))).toEqual([]);
90+
});
91+
});
92+
93+
describe('every run-user spelling formulas, RLS and the client accept', () => {
94+
it.each([
95+
['ctx.user.id == "u1"', 'ctx.user', 'ctx'],
96+
['os.user.id == "u1"', 'os.user', 'os'],
97+
['has(user.id)', 'user', 'user'],
98+
['user.?id.orValue("") == "u1"', 'user', 'user'],
99+
['"admin" in user.positions', 'user', 'user'],
100+
])('`%s` is refused naming `current_user`', (source, spelling, root) => {
101+
const found = errorsOf(flowStack({ edgeCondition: source }));
102+
expect(found).toHaveLength(1);
103+
expect(found[0]!.message.startsWith(`\`${spelling}\` is not bound in a flow expression, so the run fails with \`Unknown variable: ${root}\``)).toBe(true);
104+
expect(found[0]!.message).toContain(CURRENT_USER_REMEDY);
105+
expect(found[0]!.message).toContain(`(\`${spelling}.id\` → \`current_user.id\`)`);
106+
});
107+
108+
it('`ctx` / `os` read for something other than the user get the general refusal, naming the root', () => {
109+
const found = errorsOf(flowStack({ edgeCondition: 'os.env == "prod"' }));
110+
expect(found).toHaveLength(1);
111+
expect(found[0]!.message).toMatch(/^`os` is not bound in this flow's expression scope/);
112+
expect(found[0]!.message).not.toContain('current_user` in its place');
113+
});
114+
});
115+
116+
describe('every flow CEL site the door visits', () => {
117+
const USER = 'user.id == "u1"';
118+
const env = (source: string) => ({ dialect: 'cel', source });
119+
120+
it.each([
121+
['the start node\'s trigger gate', flowStack({ startCondition: USER }), "flow 'f' · node 'start' (start) condition"],
122+
['an edge', flowStack({ edgeCondition: USER }), "flow 'f' · edge 'e1' (start→done) condition"],
123+
['another node\'s `config.condition`', flowStack({ middle: [{ id: 'gate', type: 'decision', config: { condition: USER } }] }),
124+
"flow 'f' · node 'gate' (decision) condition"],
125+
['a decision branch', flowStack({ middle: [{ id: 'pick', type: 'decision', config: { conditions: [{ label: 'y', expression: USER }] } }] }),
126+
"flow 'f' · node 'pick' (decision) decision branch expression at config.conditions[0].expression"],
127+
['a screen field `visibleWhen`', flowStack({ middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'note', type: 'text', visibleWhen: USER }] } }] }),
128+
"flow 'f' · node 'ask' (screen) screen field visibleWhen at config.fields[0].visibleWhen"],
129+
['an assignment value envelope', flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: env('user.id') } } }] }),
130+
"flow 'f' · node 'set' (assignment) assignment value at config.assignments.who"],
131+
['a create_record field envelope', flowStack({ middle: [{ id: 'mk', type: 'create_record', config: { objectName: 'acct', fields: { status: env('user.id') } } }] }),
132+
"flow 'f' · node 'mk' (create_record) create_record field value at config.fields.status"],
133+
['an update_record field envelope', flowStack({ middle: [{ id: 'up', type: 'update_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, fields: { status: env('user.id') } } }] }),
134+
"flow 'f' · node 'up' (update_record) update_record field value at config.fields.status"],
135+
])('%s', (_site, stack, where) => {
136+
const found = rootFindings(stack);
137+
expect(found.map((i) => i.where)).toEqual([where]);
138+
expect(found[0]!.message).toContain(CURRENT_USER_REMEDY);
139+
});
140+
141+
it('an expression inside an ADR-0031 region body is judged against the same flow-wide set', () => {
142+
const found = rootFindings(flowStack({
143+
middle: [{
144+
id: 'sweep', type: 'loop',
145+
config: { collection: '{rows}', iteratorVariable: 'row', body: { nodes: [{ id: 'inner', type: 'decision', config: { conditions: [{ label: 'y', expression: 'user.id == row.owner' }] } }], edges: [] } },
146+
}],
147+
variables: [{ name: 'rows', type: 'list' }],
148+
}));
149+
expect(found).toHaveLength(1);
150+
expect(found[0]!.where).toContain("loop 'sweep' body");
151+
expect(found[0]!.message).toContain(CURRENT_USER_REMEDY);
152+
});
153+
154+
it('a value slot\'s plain string is a literal, so its text is not read for roots', () => {
155+
expect(rootFindings(flowStack({ middle: [{ id: 'set', type: 'assignment', config: { assignments: { who: 'user.id' } } }] }))).toEqual([]);
156+
});
157+
});
158+
159+
describe('controls — one per binding kind the run has', () => {
160+
it.each<[string, Parameters<typeof flowStack>[0]]>([
161+
['a declared variable', { edgeCondition: 'quota > 1', variables: [{ name: 'quota', type: 'number' }] }],
162+
['an `outputVariable`', { edgeCondition: 'acc.status == "x"', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' }, outputVariable: 'acc' } }] }],
163+
['a node id (its outputs land under it)', { edgeCondition: 'read.record != null', middle: [{ id: 'read', type: 'get_record', config: { objectName: 'acct', filter: { id: '{record.id}' } } }] }],
164+
['an `assignment` target', { edgeCondition: 'flag == true', middle: [{ id: 'set', type: 'assignment', config: { assignments: { flag: true } } }] }],
165+
['a `loop` iterator and index', { edgeCondition: 'row != null && i >= 0', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', iteratorVariable: 'row', indexVariable: 'i', body: { nodes: [], edges: [] } } }] }],
166+
['a `loop` with no iterator binds the default `item`', { edgeCondition: 'item != null', variables: [{ name: 'rows', type: 'list' }], middle: [{ id: 'sweep', type: 'loop', config: { collection: '{rows}', body: { nodes: [], edges: [] } } }] }],
167+
['a `try_catch` `errorVariable`', { edgeCondition: 'caught != null', middle: [{ id: 'guard', type: 'try_catch', config: { errorVariable: 'caught', try: { nodes: [], edges: [] }, catch: { nodes: [], edges: [] } } }] }],
168+
['a screen field (a resume binds it under its name)', { edgeCondition: 'approve == true', middle: [{ id: 'ask', type: 'screen', config: { fields: [{ name: 'approve', type: 'boolean' }] } }] }],
169+
['a trigger field, read bare', { edgeCondition: 'status == "open" && amount > 5', objectName: 'acct' }],
170+
['a field of another object the stack declares (an action, a subflow parent or a map item can hand its record in)', { edgeCondition: 'region == "eu"', objectName: 'acct', objects: [...OBJECTS, { name: 'branch', fields: { region: { type: 'text' } } }] }],
171+
['a registry-injected column', { edgeCondition: 'owner_id != null', objectName: 'acct' }],
172+
['the engine roots', { edgeCondition: 'record != null && previous == null && vars.x == null && current_user != null' }],
173+
['a `$` variable through `vars` (a `$` name has no CEL spelling)', { edgeCondition: 'vars["$record"] != null' }],
174+
['a comprehension macro variable', { edgeCondition: '[1, 2].exists(user, user > 1) && [1].map(x, x > 0, x + 1).size() == 1' }],
175+
['a `cel.bind` variable', { edgeCondition: 'cel.bind(user, 2, user > 1)' }],
176+
['a CEL type name', { edgeCondition: 'type(record) == map && int("1") == 1' }],
177+
])('%s', (_kind, opts) => {
178+
expect(rootFindings(flowStack(opts))).toEqual([]);
179+
});
180+
181+
it('a binding declared by a node AFTER the reading edge still counts — one variable map serves the whole run', () => {
182+
expect(rootFindings(flowStack({
183+
edgeCondition: 'later == 1',
184+
middle: [{ id: 'tail', type: 'assignment', config: { assignments: { later: 1 } } }],
185+
}))).toEqual([]);
186+
});
187+
188+
it('a `cel.bind` variable is bound in its body only — the initializer reads the flow\'s scope', () => {
189+
const found = rootFindings(flowStack({ edgeCondition: 'cel.bind(v, v, v > 1)' }));
190+
expect(found).toHaveLength(1);
191+
expect(found[0]!.message).toMatch(/^`v` is not bound in this flow's expression scope/);
192+
});
193+
});
194+
195+
describe('the door stands down where the run can bind a name it cannot see', () => {
196+
it.each<[string, AnyRec]>([
197+
['a `script` (its function gets the live variable map)', { id: 'fn', type: 'script', config: { function: 'enrich' } }],
198+
['a `connector_action` (its handler gets the live variable map)', { id: 'push', type: 'connector_action', config: { connectorId: 'slack', actionId: 'post' } }],
199+
['a `wait` (a resume folds the caller\'s bag)', { id: 'hold', type: 'wait', config: { eventType: 'signal', signalName: 'go' } }],
200+
['a `subflow` (a resume folds the caller\'s bag)', { id: 'child', type: 'subflow', config: { flowName: 'other' } }],
201+
['a `map` (a resume folds the caller\'s bag)', { id: 'each', type: 'map', config: { collection: '{rows}', flowName: 'other' } }],
202+
['a plugin node type', { id: 'ai', type: 'ai_classify', config: {} }],
203+
['a screen with no fields', { id: 'ok', type: 'screen', config: { waitForInput: true } }],
204+
['an object-form screen', { id: 'form', type: 'screen', config: { kind: 'object-form', objectName: 'acct', idVariable: 'saved' } }],
205+
])('%s', (_why, node) => {
206+
expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1', middle: [node] }))).toEqual([]);
207+
});
208+
209+
it('a trigger object the stack does not declare', () => {
210+
expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1"', objectName: 'sys_user' }))).toEqual([]);
211+
});
212+
213+
it('RED CONTROL — the same condition on the same flow with no opening node is refused, twice', () => {
214+
expect(rootFindings(flowStack({ edgeCondition: 'user.id == "u1" && foo == 1' })).map((i) => i.message.slice(0, 6)))
215+
.toEqual(['`user`', '`foo` ']);
216+
});
217+
218+
it('the scope says why', () => {
219+
const scope = flowCelRootScope({ nodes: [] }, [{ nodes: [{ id: 'fn', type: 'script', config: {} }] }], undefined, new Map());
220+
expect(scope.provable).toBe(false);
221+
expect(scope.openedBy).toContain("node 'fn' (script)");
222+
expect(unboundFlowCelRoots('user.id == "u1"', scope)).toEqual([]);
223+
});
224+
});
225+
226+
describe('the wider family — any root nothing binds (rides the same judge)', () => {
227+
it('`foo.bar == 1` is refused naming the root and what could have bound it', () => {
228+
const found = errorsOf(flowStack({ edgeCondition: 'foo.bar == 1' }));
229+
expect(found).toHaveLength(1);
230+
expect(found[0]!.message).toBe(
231+
'`foo` is not bound in this flow\'s expression scope, so the run fails with `Unknown variable: foo`: no variable, '
232+
+ '`outputVariable`, iterator, index or error variable, `assignment` target, node id or screen field of this flow is '
233+
+ 'named `foo`, no object in the stack declares a field `foo`, and the engine binds only `record`, `previous`, '
234+
+ '`vars` and `current_user`. Declare `foo` as a flow variable or bind it with a node, or correct the name.',
235+
);
236+
});
237+
238+
it('each unbound root is its own finding; a bound one beside it is not', () => {
239+
const found = errorsOf(flowStack({ edgeCondition: 'alpha == 1 && status == "x" && beta == 2', objectName: 'acct' }));
240+
expect(found.map((i) => i.message.slice(0, 7))).toEqual(['`alpha`', '`beta` ']);
241+
});
242+
243+
it('a near-miss of a trigger field is ONE finding: the refusal carries the did-you-mean, the advisory is withdrawn', () => {
244+
const issues = validateStackExpressions(flowStack({ edgeCondition: 'stauts == "open"', objectName: 'acct' }));
245+
expect(issues).toHaveLength(1);
246+
expect(issues[0]!.severity).toBe('error');
247+
expect(issues[0]!.message).toContain('did you mean `status`?');
248+
});
249+
250+
it('a source that does not parse is the syntax check\'s alone — one broken expression, one finding', () => {
251+
const issues = validateStackExpressions(flowStack({ edgeCondition: 'user.id ==' }));
252+
expect(issues).toHaveLength(1);
253+
expect(issues[0]!.message).not.toContain('is not bound');
254+
});
255+
});

0 commit comments

Comments
 (0)