@sweny-ai/core 0.1.161 → 0.1.162
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +3 -1
- package/dist/browser.js +2 -0
- package/dist/cli/run-output.js +1 -1
- package/dist/executor.js +89 -9
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/mermaid.js +4 -2
- package/dist/schema.d.ts +53 -12
- package/dist/schema.js +85 -3
- package/dist/studio.d.ts +2 -2
- package/dist/types.d.ts +16 -2
- package/dist/when.d.ts +114 -0
- package/dist/when.js +581 -0
- package/dist/workflows/browser.js +1 -1
- package/dist/workflows/triage.yml +15 -4
- package/package.json +1 -1
package/dist/when.js
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic `when` expressions (#461).
|
|
3
|
+
*
|
|
4
|
+
* An edge's `when` is either natural language (a model picks the edge at
|
|
5
|
+
* runtime) or `{ expr: "..." }`, a small boolean expression over prior nodes'
|
|
6
|
+
* declared output fields that sweny evaluates itself, with no model call.
|
|
7
|
+
*
|
|
8
|
+
* Grammar (precedence low to high):
|
|
9
|
+
*
|
|
10
|
+
* expr := or
|
|
11
|
+
* or := and ( "||" and )*
|
|
12
|
+
* and := unary ( "&&" unary )*
|
|
13
|
+
* unary := "!" unary | comparison
|
|
14
|
+
* comparison := operand ( ( "==" | "!=" | "<" | "<=" | ">" | ">=" | "in" ) operand )?
|
|
15
|
+
* operand := literal | list | path | "exists" path | "(" expr ")"
|
|
16
|
+
* list := "[" ( literal ( "," literal )* )? "]"
|
|
17
|
+
* literal := number | 'string' | "string" | true | false | null
|
|
18
|
+
* path := ident ( "." ident )+ (node id, then field, then nested fields)
|
|
19
|
+
*
|
|
20
|
+
* Semantics are strict and total. There is no coercion and no truthiness:
|
|
21
|
+
* `&&`, `||`, `!` and the expression itself need booleans; `<` and friends
|
|
22
|
+
* need two numbers or two strings; `in` needs a list on the right. A missing
|
|
23
|
+
* field (undefined, or a node that did not run or did not succeed) or a type
|
|
24
|
+
* mismatch makes the WHOLE expression false and is reported, so a negation
|
|
25
|
+
* can never turn a missing field into a silent true. `exists path` is the
|
|
26
|
+
* one explicit presence test: true when the value is present and not null,
|
|
27
|
+
* never a warning. `&&` and `||` short-circuit, so `exists a.x && a.x > 0`
|
|
28
|
+
* is safe.
|
|
29
|
+
*
|
|
30
|
+
* Pure module: no I/O, no eval, no Function, browser-safe. Strings in the
|
|
31
|
+
* routed data are compared as data only; nothing in them is ever parsed.
|
|
32
|
+
*/
|
|
33
|
+
/** Longest expression source accepted. Routing conditions are short. */
|
|
34
|
+
export const WHEN_EXPR_MAX_LENGTH = 2000;
|
|
35
|
+
/** Deepest nesting accepted (parens, `!`, operators), so recursion stays bounded. */
|
|
36
|
+
const MAX_DEPTH = 64;
|
|
37
|
+
// ─── Edge `when` helpers ─────────────────────────────────────────
|
|
38
|
+
/** True when an edge `when` is the structured `{ expr }` form. */
|
|
39
|
+
export function isWhenExpression(when) {
|
|
40
|
+
return (when !== null &&
|
|
41
|
+
typeof when === "object" &&
|
|
42
|
+
!Array.isArray(when) &&
|
|
43
|
+
typeof when.expr === "string");
|
|
44
|
+
}
|
|
45
|
+
/** Human-readable label for a `when`: the NL text or the expression source. */
|
|
46
|
+
export function whenLabel(when) {
|
|
47
|
+
if (when === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
return isWhenExpression(when) ? when.expr : when;
|
|
50
|
+
}
|
|
51
|
+
export class ExpressionSyntaxError extends Error {
|
|
52
|
+
position;
|
|
53
|
+
constructor(message, position) {
|
|
54
|
+
super(`${message} (at position ${position})`);
|
|
55
|
+
this.position = position;
|
|
56
|
+
this.name = "ExpressionSyntaxError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const TWO_CHAR_OPS = new Set(["&&", "||", "==", "!=", "<=", ">="]);
|
|
60
|
+
const ONE_CHAR_OPS = new Set(["!", "<", ">", "(", ")", "[", "]", ",", "."]);
|
|
61
|
+
const IDENT_START = /[A-Za-z_]/;
|
|
62
|
+
const IDENT_PART = /[A-Za-z0-9_-]/;
|
|
63
|
+
const DIGIT = /[0-9]/;
|
|
64
|
+
function tokenize(src) {
|
|
65
|
+
const tokens = [];
|
|
66
|
+
let i = 0;
|
|
67
|
+
while (i < src.length) {
|
|
68
|
+
const c = src[i];
|
|
69
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
70
|
+
i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const two = src.slice(i, i + 2);
|
|
74
|
+
if (TWO_CHAR_OPS.has(two)) {
|
|
75
|
+
tokens.push({ t: "op", v: two, pos: i });
|
|
76
|
+
i += 2;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (c === "=" || c === "&" || c === "|") {
|
|
80
|
+
const hint = c === "=" ? "==" : c + c;
|
|
81
|
+
throw new ExpressionSyntaxError(`unexpected '${c}'; did you mean '${hint}'?`, i);
|
|
82
|
+
}
|
|
83
|
+
if (ONE_CHAR_OPS.has(c)) {
|
|
84
|
+
tokens.push({ t: "op", v: c, pos: i });
|
|
85
|
+
i++;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (c === "'" || c === '"') {
|
|
89
|
+
const start = i;
|
|
90
|
+
let out = "";
|
|
91
|
+
i++;
|
|
92
|
+
for (;;) {
|
|
93
|
+
if (i >= src.length)
|
|
94
|
+
throw new ExpressionSyntaxError("unterminated string", start);
|
|
95
|
+
const ch = src[i];
|
|
96
|
+
if (ch === c) {
|
|
97
|
+
i++;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
if (ch === "\\") {
|
|
101
|
+
const nx = src[i + 1];
|
|
102
|
+
if (nx === "\\" || nx === "'" || nx === '"')
|
|
103
|
+
out += nx;
|
|
104
|
+
else if (nx === "n")
|
|
105
|
+
out += "\n";
|
|
106
|
+
else if (nx === "t")
|
|
107
|
+
out += "\t";
|
|
108
|
+
else
|
|
109
|
+
throw new ExpressionSyntaxError(`unknown escape '\\${nx ?? ""}'`, i);
|
|
110
|
+
i += 2;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
out += ch;
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
tokens.push({ t: "str", v: out, pos: start });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (DIGIT.test(c) || (c === "-" && DIGIT.test(src[i + 1] ?? ""))) {
|
|
120
|
+
const start = i;
|
|
121
|
+
if (c === "-")
|
|
122
|
+
i++;
|
|
123
|
+
while (i < src.length && DIGIT.test(src[i]))
|
|
124
|
+
i++;
|
|
125
|
+
if (src[i] === "." && DIGIT.test(src[i + 1] ?? "")) {
|
|
126
|
+
i++;
|
|
127
|
+
while (i < src.length && DIGIT.test(src[i]))
|
|
128
|
+
i++;
|
|
129
|
+
}
|
|
130
|
+
if (i < src.length && IDENT_PART.test(src[i])) {
|
|
131
|
+
throw new ExpressionSyntaxError(`malformed number '${src.slice(start, i + 1)}'`, start);
|
|
132
|
+
}
|
|
133
|
+
tokens.push({ t: "num", v: Number(src.slice(start, i)), pos: start });
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (IDENT_START.test(c)) {
|
|
137
|
+
const start = i;
|
|
138
|
+
while (i < src.length && IDENT_PART.test(src[i]))
|
|
139
|
+
i++;
|
|
140
|
+
tokens.push({ t: "ident", v: src.slice(start, i), pos: start });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
throw new ExpressionSyntaxError(`unexpected character '${c}'`, i);
|
|
144
|
+
}
|
|
145
|
+
tokens.push({ t: "eof", pos: src.length });
|
|
146
|
+
return tokens;
|
|
147
|
+
}
|
|
148
|
+
// ─── Parser ──────────────────────────────────────────────────────
|
|
149
|
+
const KEYWORDS = new Set(["true", "false", "null", "in", "exists"]);
|
|
150
|
+
const CMP_OPS = new Set(["==", "!=", "<", "<=", ">", ">="]);
|
|
151
|
+
/**
|
|
152
|
+
* Parse an expression source into an AST. Throws `ExpressionSyntaxError` on
|
|
153
|
+
* any syntax problem, including an empty source, an over-long source, or
|
|
154
|
+
* nesting deeper than the cap.
|
|
155
|
+
*/
|
|
156
|
+
export function parseExpression(src) {
|
|
157
|
+
if (typeof src !== "string" || src.trim() === "") {
|
|
158
|
+
throw new ExpressionSyntaxError("empty expression", 0);
|
|
159
|
+
}
|
|
160
|
+
if (src.length > WHEN_EXPR_MAX_LENGTH) {
|
|
161
|
+
throw new ExpressionSyntaxError(`expression longer than ${WHEN_EXPR_MAX_LENGTH} characters`, 0);
|
|
162
|
+
}
|
|
163
|
+
const tokens = tokenize(src);
|
|
164
|
+
let p = 0;
|
|
165
|
+
let depth = 0;
|
|
166
|
+
const peek = () => tokens[p];
|
|
167
|
+
const isOp = (v) => {
|
|
168
|
+
const tk = tokens[p];
|
|
169
|
+
return tk.t === "op" && tk.v === v;
|
|
170
|
+
};
|
|
171
|
+
const isKw = (v) => {
|
|
172
|
+
const tk = tokens[p];
|
|
173
|
+
return tk.t === "ident" && tk.v === v;
|
|
174
|
+
};
|
|
175
|
+
const describe = (tk) => tk.t === "eof" ? "end of expression" : tk.t === "str" ? `string '${tk.v}'` : `'${String(tk.v)}'`;
|
|
176
|
+
const expectOp = (v) => {
|
|
177
|
+
if (!isOp(v))
|
|
178
|
+
throw new ExpressionSyntaxError(`expected '${v}' but found ${describe(peek())}`, peek().pos);
|
|
179
|
+
p++;
|
|
180
|
+
};
|
|
181
|
+
const enter = () => {
|
|
182
|
+
if (++depth > MAX_DEPTH)
|
|
183
|
+
throw new ExpressionSyntaxError(`nesting deeper than ${MAX_DEPTH}`, peek().pos);
|
|
184
|
+
};
|
|
185
|
+
function parseOr() {
|
|
186
|
+
enter();
|
|
187
|
+
let left = parseAnd();
|
|
188
|
+
while (isOp("||")) {
|
|
189
|
+
p++;
|
|
190
|
+
left = { kind: "or", left, right: parseAnd() };
|
|
191
|
+
}
|
|
192
|
+
depth--;
|
|
193
|
+
return left;
|
|
194
|
+
}
|
|
195
|
+
function parseAnd() {
|
|
196
|
+
let left = parseUnary();
|
|
197
|
+
while (isOp("&&")) {
|
|
198
|
+
p++;
|
|
199
|
+
left = { kind: "and", left, right: parseUnary() };
|
|
200
|
+
}
|
|
201
|
+
return left;
|
|
202
|
+
}
|
|
203
|
+
function parseUnary() {
|
|
204
|
+
if (isOp("!")) {
|
|
205
|
+
p++;
|
|
206
|
+
enter();
|
|
207
|
+
const operand = parseUnary();
|
|
208
|
+
depth--;
|
|
209
|
+
return { kind: "not", operand };
|
|
210
|
+
}
|
|
211
|
+
return parseComparison();
|
|
212
|
+
}
|
|
213
|
+
function parseComparison() {
|
|
214
|
+
const left = parseOperand();
|
|
215
|
+
const tk = peek();
|
|
216
|
+
if (tk.t === "op" && CMP_OPS.has(tk.v)) {
|
|
217
|
+
p++;
|
|
218
|
+
return { kind: "cmp", op: tk.v, left, right: parseOperand() };
|
|
219
|
+
}
|
|
220
|
+
if (isKw("in")) {
|
|
221
|
+
p++;
|
|
222
|
+
return { kind: "cmp", op: "in", left, right: parseOperand() };
|
|
223
|
+
}
|
|
224
|
+
return left;
|
|
225
|
+
}
|
|
226
|
+
function parsePath() {
|
|
227
|
+
const tk = peek();
|
|
228
|
+
if (tk.t !== "ident" || KEYWORDS.has(tk.v)) {
|
|
229
|
+
throw new ExpressionSyntaxError(`expected a field path but found ${describe(tk)}`, tk.pos);
|
|
230
|
+
}
|
|
231
|
+
p++;
|
|
232
|
+
const segments = [tk.v];
|
|
233
|
+
while (isOp(".")) {
|
|
234
|
+
p++;
|
|
235
|
+
const seg = peek();
|
|
236
|
+
if (seg.t !== "ident") {
|
|
237
|
+
throw new ExpressionSyntaxError(`expected a field name after '.' but found ${describe(seg)}`, seg.pos);
|
|
238
|
+
}
|
|
239
|
+
p++;
|
|
240
|
+
segments.push(seg.v);
|
|
241
|
+
}
|
|
242
|
+
if (segments.length < 2) {
|
|
243
|
+
throw new ExpressionSyntaxError(`path '${tk.v}' needs a node and a field (e.g. '${tk.v}.field'); quote it if you meant a string`, tk.pos);
|
|
244
|
+
}
|
|
245
|
+
return { kind: "path", segments };
|
|
246
|
+
}
|
|
247
|
+
function parseLiteral() {
|
|
248
|
+
const tk = peek();
|
|
249
|
+
if (tk.t === "num" || tk.t === "str") {
|
|
250
|
+
p++;
|
|
251
|
+
return tk.v;
|
|
252
|
+
}
|
|
253
|
+
if (tk.t === "ident" && (tk.v === "true" || tk.v === "false" || tk.v === "null")) {
|
|
254
|
+
p++;
|
|
255
|
+
return tk.v === "null" ? null : tk.v === "true";
|
|
256
|
+
}
|
|
257
|
+
throw new ExpressionSyntaxError(`expected a literal but found ${describe(tk)}`, tk.pos);
|
|
258
|
+
}
|
|
259
|
+
function parseOperand() {
|
|
260
|
+
const tk = peek();
|
|
261
|
+
if (isOp("(")) {
|
|
262
|
+
p++;
|
|
263
|
+
const inner = parseOr();
|
|
264
|
+
expectOp(")");
|
|
265
|
+
return inner;
|
|
266
|
+
}
|
|
267
|
+
if (isOp("[")) {
|
|
268
|
+
p++;
|
|
269
|
+
const items = [];
|
|
270
|
+
if (!isOp("]")) {
|
|
271
|
+
items.push(parseLiteral());
|
|
272
|
+
while (isOp(",")) {
|
|
273
|
+
p++;
|
|
274
|
+
items.push(parseLiteral());
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
expectOp("]");
|
|
278
|
+
return { kind: "list", items };
|
|
279
|
+
}
|
|
280
|
+
if (tk.t === "num" || tk.t === "str")
|
|
281
|
+
return { kind: "literal", value: parseLiteral() };
|
|
282
|
+
if (tk.t === "ident") {
|
|
283
|
+
if (tk.v === "true" || tk.v === "false" || tk.v === "null")
|
|
284
|
+
return { kind: "literal", value: parseLiteral() };
|
|
285
|
+
if (tk.v === "exists") {
|
|
286
|
+
p++;
|
|
287
|
+
return { kind: "exists", path: parsePath() };
|
|
288
|
+
}
|
|
289
|
+
if (tk.v === "in")
|
|
290
|
+
throw new ExpressionSyntaxError(`unexpected 'in'`, tk.pos);
|
|
291
|
+
return parsePath();
|
|
292
|
+
}
|
|
293
|
+
throw new ExpressionSyntaxError(`unexpected ${describe(tk)}`, tk.pos);
|
|
294
|
+
}
|
|
295
|
+
const ast = parseOr();
|
|
296
|
+
if (peek().t !== "eof") {
|
|
297
|
+
throw new ExpressionSyntaxError(`unexpected ${describe(peek())} after a complete expression`, peek().pos);
|
|
298
|
+
}
|
|
299
|
+
return ast;
|
|
300
|
+
}
|
|
301
|
+
class Abort extends Error {
|
|
302
|
+
}
|
|
303
|
+
function typeName(v) {
|
|
304
|
+
if (v === null)
|
|
305
|
+
return "null";
|
|
306
|
+
if (Array.isArray(v))
|
|
307
|
+
return "list";
|
|
308
|
+
return typeof v;
|
|
309
|
+
}
|
|
310
|
+
function isPrimitive(v) {
|
|
311
|
+
return v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean";
|
|
312
|
+
}
|
|
313
|
+
function lookup(scope, segments) {
|
|
314
|
+
let cur = Object.prototype.hasOwnProperty.call(scope, segments[0]) ? scope[segments[0]] : undefined;
|
|
315
|
+
for (let i = 1; i < segments.length; i++) {
|
|
316
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
|
317
|
+
return undefined;
|
|
318
|
+
const obj = cur;
|
|
319
|
+
cur = Object.prototype.hasOwnProperty.call(obj, segments[i]) ? obj[segments[i]] : undefined;
|
|
320
|
+
}
|
|
321
|
+
return cur;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Evaluate a parsed expression against node outputs. Never throws: a missing
|
|
325
|
+
* field or a type mismatch yields `{ value: false, problem }`.
|
|
326
|
+
*/
|
|
327
|
+
export function evaluateExpression(ast, scope) {
|
|
328
|
+
const read = (path) => {
|
|
329
|
+
const v = lookup(scope, path);
|
|
330
|
+
if (v === undefined)
|
|
331
|
+
throw new Abort(`field '${path.join(".")}' is missing`);
|
|
332
|
+
return v;
|
|
333
|
+
};
|
|
334
|
+
const bool = (v, where) => {
|
|
335
|
+
if (typeof v !== "boolean")
|
|
336
|
+
throw new Abort(`${where} needs a boolean, got ${typeName(v)}`);
|
|
337
|
+
return v;
|
|
338
|
+
};
|
|
339
|
+
function ev(node) {
|
|
340
|
+
switch (node.kind) {
|
|
341
|
+
case "literal":
|
|
342
|
+
return node.value;
|
|
343
|
+
case "list":
|
|
344
|
+
return node.items;
|
|
345
|
+
case "path":
|
|
346
|
+
return read(node.segments);
|
|
347
|
+
case "exists": {
|
|
348
|
+
const v = lookup(scope, node.path.segments);
|
|
349
|
+
return v !== undefined && v !== null;
|
|
350
|
+
}
|
|
351
|
+
case "not":
|
|
352
|
+
return !bool(ev(node.operand), "'!'");
|
|
353
|
+
case "and":
|
|
354
|
+
return bool(ev(node.left), "'&&'") ? bool(ev(node.right), "'&&'") : false;
|
|
355
|
+
case "or":
|
|
356
|
+
return bool(ev(node.left), "'||'") ? true : bool(ev(node.right), "'||'");
|
|
357
|
+
case "cmp": {
|
|
358
|
+
const l = ev(node.left);
|
|
359
|
+
const r = ev(node.right);
|
|
360
|
+
if (node.op === "in") {
|
|
361
|
+
if (!Array.isArray(r))
|
|
362
|
+
throw new Abort(`'in' needs a list on the right, got ${typeName(r)}`);
|
|
363
|
+
if (!isPrimitive(l))
|
|
364
|
+
throw new Abort(`'in' needs a string, number, boolean or null on the left`);
|
|
365
|
+
return r.some((x) => x === l);
|
|
366
|
+
}
|
|
367
|
+
if (node.op === "==" || node.op === "!=") {
|
|
368
|
+
if (!isPrimitive(l) || !isPrimitive(r)) {
|
|
369
|
+
throw new Abort(`'${node.op}' compares strings, numbers, booleans or null, got ${typeName(l)} and ${typeName(r)}`);
|
|
370
|
+
}
|
|
371
|
+
return node.op === "==" ? l === r : l !== r;
|
|
372
|
+
}
|
|
373
|
+
const bothNum = typeof l === "number" && typeof r === "number";
|
|
374
|
+
const bothStr = typeof l === "string" && typeof r === "string";
|
|
375
|
+
if (!bothNum && !bothStr) {
|
|
376
|
+
throw new Abort(`'${node.op}' needs two numbers or two strings, got ${typeName(l)} and ${typeName(r)}`);
|
|
377
|
+
}
|
|
378
|
+
const a = l;
|
|
379
|
+
const b = r;
|
|
380
|
+
switch (node.op) {
|
|
381
|
+
case "<":
|
|
382
|
+
return a < b;
|
|
383
|
+
case "<=":
|
|
384
|
+
return a <= b;
|
|
385
|
+
case ">":
|
|
386
|
+
return a > b;
|
|
387
|
+
default:
|
|
388
|
+
return a >= b;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
try {
|
|
394
|
+
const v = ev(ast);
|
|
395
|
+
if (typeof v !== "boolean")
|
|
396
|
+
return { value: false, problem: `expression produced ${typeName(v)}, not a boolean` };
|
|
397
|
+
return { value: v };
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
if (err instanceof Abort)
|
|
401
|
+
return { value: false, problem: err.message };
|
|
402
|
+
throw err;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// ─── Load-time checks ────────────────────────────────────────────
|
|
406
|
+
/**
|
|
407
|
+
* Check an expression against the workflow it routes. Returns one message per
|
|
408
|
+
* problem, empty when the expression is valid:
|
|
409
|
+
* - the source must parse;
|
|
410
|
+
* - every path's node must be the edge's source or one of its ancestors;
|
|
411
|
+
* - that node must declare `output.properties`, and the field (and any
|
|
412
|
+
* nested field) must be declared there;
|
|
413
|
+
* - a literal compared with a declared field must fit its declared type,
|
|
414
|
+
* and a string literal compared with an enum field must be in the enum;
|
|
415
|
+
* - a field used where a boolean is needed must be declared boolean.
|
|
416
|
+
*/
|
|
417
|
+
export function checkExpression(src, ctx) {
|
|
418
|
+
let ast;
|
|
419
|
+
try {
|
|
420
|
+
ast = parseExpression(src);
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
return [err instanceof Error ? err.message : String(err)];
|
|
424
|
+
}
|
|
425
|
+
const problems = [];
|
|
426
|
+
const resolved = new Map();
|
|
427
|
+
const resolve = (segments, node) => {
|
|
428
|
+
const [nodeId, ...fields] = segments;
|
|
429
|
+
const label = segments.join(".");
|
|
430
|
+
if (!Object.prototype.hasOwnProperty.call(ctx.outputs, nodeId)) {
|
|
431
|
+
problems.push(`'${label}': no node named '${nodeId}'`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (!ctx.ancestors.has(nodeId)) {
|
|
435
|
+
problems.push(`'${label}': node '${nodeId}' does not run before '${ctx.from}' on any path`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
let schema = ctx.outputs[nodeId];
|
|
439
|
+
const props0 = schema?.properties;
|
|
440
|
+
if (!props0 || typeof props0 !== "object" || Object.keys(props0).length === 0) {
|
|
441
|
+
problems.push(`'${label}': node '${nodeId}' declares no output.properties, so it has no fields to route on`);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
for (let i = 0; i < fields.length; i++) {
|
|
445
|
+
const props = schema?.properties;
|
|
446
|
+
if (!props || typeof props !== "object") {
|
|
447
|
+
if (schema && schema.type === "array") {
|
|
448
|
+
problems.push(`'${label}': '${[nodeId, ...fields.slice(0, i)].join(".")}' is a list; expressions cannot index lists`);
|
|
449
|
+
}
|
|
450
|
+
else if (schema &&
|
|
451
|
+
(schema.type === "string" ||
|
|
452
|
+
schema.type === "number" ||
|
|
453
|
+
schema.type === "integer" ||
|
|
454
|
+
schema.type === "boolean")) {
|
|
455
|
+
problems.push(`'${label}': '${[nodeId, ...fields.slice(0, i)].join(".")}' is a ${String(schema.type)}, not an object`);
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
// Free-form object: nested fields are not declared, so accept them.
|
|
459
|
+
resolved.set(node, null);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
if (!Object.prototype.hasOwnProperty.call(props, fields[i])) {
|
|
465
|
+
problems.push(`'${label}': '${fields[i]}' is not a declared output field of '${[nodeId, ...fields.slice(0, i)].join(".")}'`);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
schema = props[fields[i]];
|
|
469
|
+
}
|
|
470
|
+
resolved.set(node, schema && typeof schema === "object" ? schema : null);
|
|
471
|
+
};
|
|
472
|
+
const walk = (n) => {
|
|
473
|
+
switch (n.kind) {
|
|
474
|
+
case "path":
|
|
475
|
+
resolve(n.segments, n);
|
|
476
|
+
return;
|
|
477
|
+
case "exists":
|
|
478
|
+
resolve(n.path.segments, n.path);
|
|
479
|
+
return;
|
|
480
|
+
case "not":
|
|
481
|
+
walk(n.operand);
|
|
482
|
+
return;
|
|
483
|
+
case "and":
|
|
484
|
+
case "or":
|
|
485
|
+
case "cmp":
|
|
486
|
+
walk(n.left);
|
|
487
|
+
walk(n.right);
|
|
488
|
+
return;
|
|
489
|
+
default:
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
walk(ast);
|
|
494
|
+
if (problems.length > 0)
|
|
495
|
+
return problems;
|
|
496
|
+
const declaredTypes = (s) => {
|
|
497
|
+
const t = s.type;
|
|
498
|
+
if (typeof t === "string")
|
|
499
|
+
return new Set([t]);
|
|
500
|
+
if (Array.isArray(t))
|
|
501
|
+
return new Set(t.filter((x) => typeof x === "string"));
|
|
502
|
+
return null;
|
|
503
|
+
};
|
|
504
|
+
const literalFits = (v, types) => {
|
|
505
|
+
if (v === null)
|
|
506
|
+
return types.has("null");
|
|
507
|
+
if (typeof v === "string")
|
|
508
|
+
return types.has("string");
|
|
509
|
+
if (typeof v === "boolean")
|
|
510
|
+
return types.has("boolean");
|
|
511
|
+
return types.has("number") || (types.has("integer") && Number.isInteger(v));
|
|
512
|
+
};
|
|
513
|
+
const label = (n) => (n.kind === "path" ? n.segments.join(".") : "");
|
|
514
|
+
const checkLiteralAgainst = (pathNode, lit) => {
|
|
515
|
+
const s = resolved.get(pathNode);
|
|
516
|
+
if (!s)
|
|
517
|
+
return;
|
|
518
|
+
// `== null` / `!= null` is always allowed: an optional field can be null.
|
|
519
|
+
if (lit === null)
|
|
520
|
+
return;
|
|
521
|
+
const types = declaredTypes(s);
|
|
522
|
+
if (types && !literalFits(lit, types)) {
|
|
523
|
+
problems.push(`'${label(pathNode)}' is declared ${[...types].join("|")} but is compared with ${JSON.stringify(lit)}`);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (Array.isArray(s.enum) && typeof lit === "string" && !s.enum.includes(lit)) {
|
|
527
|
+
problems.push(`'${label(pathNode)}' never equals ${JSON.stringify(lit)}; its declared values are ${JSON.stringify(s.enum)}`);
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
const checkBooleanPosition = (n) => {
|
|
531
|
+
if ((n.kind === "literal" && typeof n.value !== "boolean") || n.kind === "list") {
|
|
532
|
+
problems.push(`${n.kind === "list" ? "a list" : JSON.stringify(n.kind === "literal" ? n.value : null)} is not a boolean`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
if (n.kind !== "path")
|
|
536
|
+
return;
|
|
537
|
+
const s = resolved.get(n);
|
|
538
|
+
if (!s)
|
|
539
|
+
return;
|
|
540
|
+
const types = declaredTypes(s);
|
|
541
|
+
if (types && !types.has("boolean")) {
|
|
542
|
+
problems.push(`'${label(n)}' is declared ${[...types].join("|")}; use a comparison, it is not a boolean`);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
const check = (n) => {
|
|
546
|
+
switch (n.kind) {
|
|
547
|
+
case "not":
|
|
548
|
+
checkBooleanPosition(n.operand);
|
|
549
|
+
check(n.operand);
|
|
550
|
+
return;
|
|
551
|
+
case "and":
|
|
552
|
+
case "or":
|
|
553
|
+
checkBooleanPosition(n.left);
|
|
554
|
+
checkBooleanPosition(n.right);
|
|
555
|
+
check(n.left);
|
|
556
|
+
check(n.right);
|
|
557
|
+
return;
|
|
558
|
+
case "cmp":
|
|
559
|
+
if (n.op === "in") {
|
|
560
|
+
if (n.left.kind === "path" && n.right.kind === "list") {
|
|
561
|
+
for (const item of n.right.items)
|
|
562
|
+
checkLiteralAgainst(n.left, item);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else if (n.left.kind === "path" && n.right.kind === "literal") {
|
|
566
|
+
checkLiteralAgainst(n.left, n.right.value);
|
|
567
|
+
}
|
|
568
|
+
else if (n.right.kind === "path" && n.left.kind === "literal") {
|
|
569
|
+
checkLiteralAgainst(n.right, n.left.value);
|
|
570
|
+
}
|
|
571
|
+
check(n.left);
|
|
572
|
+
check(n.right);
|
|
573
|
+
return;
|
|
574
|
+
default:
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
checkBooleanPosition(ast);
|
|
579
|
+
check(ast);
|
|
580
|
+
return problems;
|
|
581
|
+
}
|