-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathvalidate-expressions.ts
More file actions
342 lines (320 loc) · 17.5 KB
/
Copy pathvalidate-expressions.ts
File metadata and controls
342 lines (320 loc) · 17.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
/**
* Build-time expression validation (ADR-0032 §Decision 1a + 1b).
*
* Runs at `objectstack compile`, where the whole normalized stack is in hand —
* so flow conditions can be checked against the *resolved* object schema
* (field existence) in addition to CEL syntax. Uses the one shared validator
* from `@objectstack/formula`, so the verdict matches `registerFlow` and the
* agent `validate_expression` tool exactly.
*
* Scope: flow predicates (start/decision `config.condition` + edge `condition`),
* every **descriptor-declared** expression slot named by
* `FLOW_NODE_EXPRESSION_PATHS` (#4027 — e.g. a screen field's `visibleWhen`),
* object validation-rule / formula predicates, and UI action `visible` /
* `disabled` predicates. Each error is located (flow/object/action +
* node/edge/field) with a corrective message.
*/
import { validateExpression } from '@objectstack/formula';
import { resolveFlowNodeExpressions } from '@objectstack/spec/automation';
export interface ExprIssue {
where: string;
message: string;
source: string;
/**
* `error` fails the build (e.g. a bare ref in a record-scoped formula). `warning`
* is advisory and never fails it (e.g. a possible field typo in a flattened flow
* condition, which might be a flow variable). Absent ⇒ treat as `error`.
*/
severity?: 'error' | 'warning';
}
type AnyRec = Record<string, unknown>;
/** Coerce an `objects` collection (array or name-keyed map) to an array. */
function asArray(v: unknown): AnyRec[] {
if (Array.isArray(v)) return v as AnyRec[];
if (v && typeof v === 'object') {
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
}
return [];
}
/** object name → set of its field names, for schema-aware field checks. */
function buildFieldIndex(objects: AnyRec[]): Map<string, string[]> {
const idx = new Map<string, string[]>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = obj.fields;
let names: string[] = [];
if (Array.isArray(fields)) names = fields.map(f => (f as AnyRec).name).filter((n): n is string => typeof n === 'string');
else if (fields && typeof fields === 'object') names = Object.keys(fields as AnyRec);
idx.set(name, names);
}
return idx;
}
/**
* object name → (field name → field type), for the #1928 tier-4 type-soundness
* check. Handles both `fields` shapes (array of `{name, type}` and name-keyed
* map). Fields with a non-string `type` are simply omitted (treated as `dyn`).
*/
function buildFieldTypeIndex(objects: AnyRec[]): Map<string, Record<string, string>> {
const idx = new Map<string, Record<string, string>>();
for (const obj of objects) {
const name = typeof obj.name === 'string' ? obj.name : undefined;
if (!name) continue;
const fields = obj.fields;
const types: Record<string, string> = {};
if (Array.isArray(fields)) {
for (const f of fields as AnyRec[]) {
const fn = (f as AnyRec)?.name;
const ft = (f as AnyRec)?.type;
if (typeof fn === 'string' && typeof ft === 'string') types[fn] = ft;
}
} else if (fields && typeof fields === 'object') {
for (const [fn, def] of Object.entries(fields as AnyRec)) {
const ft = (def as AnyRec)?.type;
if (typeof ft === 'string') types[fn] = ft;
}
}
idx.set(name, types);
}
return idx;
}
/**
* Validate every predicate in the stack. Returns the list of issues (empty =
* clean). Caller decides how to surface / whether to fail the build.
*/
export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
const issues: ExprIssue[] = [];
const objects = asArray(stack.objects);
const fieldIndex = buildFieldIndex(objects);
const fieldTypeIndex = buildFieldTypeIndex(objects);
const check = (
where: string,
raw: unknown,
objectName?: string,
scope: 'record' | 'flattened' = 'flattened',
): void => {
if (raw == null) return;
const fields = objectName ? fieldIndex.get(objectName) : undefined;
// Field types feed the #1928 tier-4 soundness warning; only consulted for
// `record`-scoped sites, so it is harmless to pass for flattened ones too.
const fieldTypes = objectName ? fieldTypeIndex.get(objectName) : undefined;
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string },
objectName ? { objectName, fields, fieldTypes, scope } : { scope });
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: 'error' });
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
};
/**
* A declared bare-CEL slot (#4027). No object schema is passed: these slots
* bind the *screen's own* collected values, not the trigger record's fields, so
* a field-existence pass would report every field name as unknown.
*/
const checkDeclaredPredicate = (where: string, raw: unknown): void => {
if (raw == null) return;
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string });
for (const e of res.errors) issues.push({ where, message: e.message, source: e.source, severity: 'error' });
for (const w of res.warnings) issues.push({ where, message: w.message, source: w.source, severity: 'warning' });
};
// ── Flows ──────────────────────────────────────────────────────────
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[]) : [];
const edges = Array.isArray(flow.edges) ? (flow.edges as AnyRec[]) : [];
// The record-change target object — `record.*` refs resolve against it.
const startNode = nodes.find(n => n.type === 'start');
const startCfg = (startNode?.config ?? {}) as AnyRec;
const objectName = typeof startCfg.objectName === 'string' ? startCfg.objectName : undefined;
for (const node of nodes) {
const cfg = (node.config ?? {}) as AnyRec;
check(`flow '${flowName}' · node '${node.id}' (${node.type}) condition`, cfg.condition, objectName);
// Descriptor-declared expression slots (#4027). Before this, the traversal
// hardcoded `condition` and assumed every other node string was a `{var}`
// template — so `screen.fields[].visibleWhen`, declared bare CEL since
// #3304, was validated by nobody and #3528 shipped a template-dialect
// predicate through compile, validate and run time in silence.
// Only `predicate` slots are checkable: `flow-template` slots take the
// single-brace `{var}` dialect `interpolate()` implements, which no
// validator covers (the `template` role enforces ADR-0032 §3's
// double-brace text template and would reject every correct
// `loop.collection`). The ledger records them regardless, so the
// reconciliation ratchet still sees the marker.
const nodeType = typeof node.type === 'string' ? node.type : '';
for (const found of resolveFlowNodeExpressions(nodeType, cfg)) {
if (found.entry.role !== 'predicate') continue;
checkDeclaredPredicate(
`flow '${flowName}' · node '${node.id}' (${nodeType}) ${found.entry.label} at config.${found.path}`,
found.value,
);
}
// #1870 — a `script` node must declare a callable target (`actionType` or
// `function`). A node with neither is a silent no-op that otherwise passes
// build. (Function *existence* isn't checkable here — functions are code,
// not serialized into the artifact — so this is a structural check; the
// runtime verifies the named function is actually registered.)
if (node.type === 'script') {
// `function` is canonical; a pre-parse source may still carry the
// `functionName` alias during the protocol-17 window, until the
// 'flow-node-script-config-aliases' conversion (#3796) canonicalizes it.
const fn =
(typeof cfg.function === 'string' ? cfg.function.trim() : '') ||
(typeof cfg.functionName === 'string' ? cfg.functionName.trim() : '');
const action = typeof cfg.actionType === 'string' ? cfg.actionType.trim() : '';
// Inline `config.script` (a JS body) is also a declared form — the
// built-in runtime doesn't execute it (warned at run time), but the node
// is not the empty no-op this check targets, so don't flag it.
const inline = typeof cfg.script === 'string' ? cfg.script.trim() : '';
if (!fn && !action && !inline) {
issues.push({
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
message:
`script node declares neither \`actionType\` nor \`function\` — it would do nothing at runtime. ` +
`Name a built-in action (e.g. \`actionType: 'email'\`) or a registered function ` +
`(\`function: 'my_fn'\`, registered via \`defineStack({ functions })\`).`,
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
});
} else if (action === 'invoke_function' && !fn) {
// `actionType: 'invoke_function'` is a marker that names no callable on
// its own — the function name must be in `function`/`functionName`.
issues.push({
where: `flow '${flowName}' · node '${node.id}' (script) callable`,
message:
`script node uses \`actionType: 'invoke_function'\` but no \`function\` (or \`functionName\`) — ` +
`it names no callable. Set \`function: 'my_fn'\` and register it via \`defineStack({ functions })\`.`,
source: JSON.stringify({ id: node.id, type: node.type, config: cfg }),
});
}
}
}
for (const edge of edges) {
check(`flow '${flowName}' · edge '${edge.id}' (${edge.source}→${edge.target}) condition`, edge.condition, objectName);
}
}
// ── Object validation-rule + formula predicates ────────────────────
for (const obj of objects) {
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
const validations = obj.validations ?? obj.validationRules;
for (const rule of asArray(validations)) {
const where = `object '${objectName}' · validation '${(rule.name as string) ?? '?'}'`;
// Common predicate keys across rule shapes. Validation predicates are
// `record`-scoped — no field flattening — so bare refs are flagged (#1928).
check(where, rule.expression ?? rule.predicate ?? rule.condition ?? rule.formula, objectName, 'record');
// `conditional` rules carry a nested `when` predicate (record-scoped).
check(`${where} when`, (rule as AnyRec).when, objectName, 'record');
}
// Field-level formulas (computed fields) reference the same object.
const fields = obj.fields;
const fieldList = Array.isArray(fields)
? (fields as AnyRec[])
: (fields && typeof fields === 'object' ? Object.values(fields as AnyRec) as AnyRec[] : []);
// (ADR-0062 D7's `field.columnName`-on-external-objects rejection was removed
// with `field.columnName` itself in #2377: the field no longer exists, so there
// is no dual-source ambiguity to guard — external column mapping is `external.columnMap`.)
for (const f of fieldList) {
// Field-level conditional rules are server-enforced (rule-validator) and
// record-scoped — a bare ref silently fails the rule (required/readonly
// not enforced = data-integrity hole). #1928 class, same as actions.
if (f && typeof f === 'object') {
const fname = (f.name as string) ?? '?';
for (const key of ['requiredWhen', 'readonlyWhen', 'conditionalRequired', 'visibleWhen'] as const) {
check(`object '${objectName}' · field '${fname}' ${key}`, (f as AnyRec)[key], objectName, 'record');
}
}
if (f && typeof f === 'object' && f.formula) {
// formulas are `value` role (any return type), still CEL. They are
// `record`-scoped — `record.<field>`, never bare — so flag bare refs (#1928).
const res = validateExpression('value', f.formula as string | { dialect?: string; source?: string },
objectName ? { objectName, fields: fieldIndex.get(objectName), fieldTypes: fieldTypeIndex.get(objectName), scope: 'record' } : { scope: 'record' });
const fieldWhere = `object '${objectName}' · field '${(f.name as string) ?? '?'}' formula`;
for (const e of res.errors) issues.push({ where: fieldWhere, message: e.message, source: e.source, severity: 'error' });
for (const w of res.warnings) issues.push({ where: fieldWhere, message: w.message, source: w.source, severity: 'warning' });
}
}
}
// ── Action `visible` / `disabled` predicates ───────────────────────
// Record-scoped, same as validation rules: a record-header / row action's
// `visible` is evaluated by ActionEngine against `{ record, recordId,
// objectName, user, … }` with fail-closed semantics, so a BARE field ref
// (`done` instead of `record.done`) throws and the action is silently hidden
// on every record (the trap behind the #2183 "Mark Done never hides" hunt).
// Flagging it here turns that into a build error with a corrective message.
// `disabled` may be a boolean (skip) or a predicate (check).
const seenActions = new Set<string>();
const checkAction = (where: string, action: AnyRec, objectName?: string): void => {
const obj = objectName
?? (typeof action.objectName === 'string' ? action.objectName : undefined)
?? (typeof action.object === 'string' ? action.object : undefined);
const name = typeof action.name === 'string' ? action.name : '?';
const key = `${obj ?? ''}:${name}`;
if (seenActions.has(key)) return; // de-dup (actions are merged onto objects AND kept top-level)
seenActions.add(key);
check(`${where} · action '${name}' visible`, action.visible, obj, 'record');
if (typeof action.disabled !== 'boolean') {
check(`${where} · action '${name}' disabled`, action.disabled, obj, 'record');
}
};
for (const action of asArray(stack.actions)) {
checkAction('stack', action);
}
for (const obj of objects) {
const objectName = typeof obj.name === 'string' ? obj.name : undefined;
for (const action of asArray(obj.actions)) {
checkAction(`object '${objectName}'`, action, objectName);
}
}
// ── Sharing-rule predicates (security-critical, record-scoped) ─────
// A criteria sharing rule's `condition` decides which rows a principal sees.
// It is evaluated against the record, so a bare ref silently changes access.
for (const rule of asArray(stack.sharingRules)) {
const ruleObj = typeof rule.object === 'string' ? rule.object : undefined;
const where = `sharingRule '${(rule.name as string) ?? '?'}'${ruleObj ? ` (${ruleObj})` : ''} condition`;
check(where, rule.condition ?? rule.criteria ?? rule.predicate, ruleObj, 'record');
}
// ── Hook `condition` predicates (record-scoped gate) ───────────────
// A lifecycle hook's `condition` skips the handler when false; it is
// evaluated against the record, so a bare ref silently makes the hook
// run on every record (or never) instead of the intended subset.
for (const hook of asArray(stack.hooks)) {
const hookName = (hook.name as string) ?? '?';
if (typeof hook.object === 'string') {
check(`hook '${hookName}' (${hook.object}) condition`, hook.condition, hook.object, 'record');
continue;
}
// A hook may target MANY objects (`object: ['a','b']`). Previously any
// non-string target dropped to `undefined`, so the condition got NO
// field-awareness at all — a hook filtering on a field that exists on none
// of its targets passed clean (issue #3583). The hook body runs against
// each target in turn, so a ref missing from ANY of them silently
// misbehaves there; validate per target and de-duplicate the
// object-independent diagnostics (syntax/shape) that every pass repeats.
const targets = Array.isArray(hook.object)
? (hook.object as unknown[]).filter((o): o is string => typeof o === 'string' && o !== '*')
: [];
if (targets.length === 0) {
// `'*'` (or an unusable shape) — no single field set to judge against;
// syntax/shape is still validated.
check(`hook '${hookName}' condition`, hook.condition, undefined, 'record');
continue;
}
const before = issues.length;
const seen = new Set<string>();
const kept: ExprIssue[] = [];
for (const target of targets) {
const mark = issues.length;
check(`hook '${hookName}' (${target}) condition`, hook.condition, target, 'record');
for (let i = mark; i < issues.length; i++) {
const issue = issues[i];
const key = `${issue.message}\u0000${issue.source ?? ''}`;
// Keep the first occurrence of each distinct diagnostic. A field-unknown
// finding differs per target (it names the object), so each survives;
// a syntax error is identical across targets and collapses to one.
if (!seen.has(key)) {
seen.add(key);
kept.push(issue);
}
}
}
issues.length = before;
issues.push(...kept);
}
return issues;
}