-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathvalidate-expressions.ts
More file actions
164 lines (152 loc) · 8.31 KB
/
Copy pathvalidate-expressions.ts
File metadata and controls
164 lines (152 loc) · 8.31 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
// 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 (v1): flow predicates (start/decision `config.condition` + edge
* `condition`) and object validation-rule / formula predicates. Each error is
* located (flow/object + node/edge/field) with a corrective message.
*/
import { validateExpression } from '@objectstack/formula';
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;
}
/**
* 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 check = (
where: string,
raw: unknown,
objectName?: string,
scope: 'record' | 'flattened' = 'flattened',
): void => {
if (raw == null) return;
const fields = objectName ? fieldIndex.get(objectName) : undefined;
const res = validateExpression('predicate', raw as string | { dialect?: string; source?: string },
objectName ? { objectName, fields, 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' });
};
// ── 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);
// #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; `functionName` is an accepted alias.
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');
}
// 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[] : []);
for (const f of fieldList) {
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), 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' });
}
}
}
return issues;
}