@itmetsam/samurai-framework 0.2.2 → 0.3.0

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.
Files changed (111) hide show
  1. package/README.md +5 -0
  2. package/bin/samurai.mjs +7 -5
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.js +6 -1
  5. package/dist/cli/flow-reporter.d.ts +23 -0
  6. package/dist/cli/flow-reporter.js +97 -0
  7. package/dist/cli/flow.d.ts +4 -0
  8. package/dist/cli/flow.js +240 -0
  9. package/dist/cli/main.d.ts +1 -1
  10. package/dist/cli/main.js +14 -0
  11. package/dist/flows/core/check.d.ts +42 -0
  12. package/dist/flows/core/check.js +202 -0
  13. package/dist/flows/core/expression/check.d.ts +21 -0
  14. package/dist/flows/core/expression/check.js +95 -0
  15. package/dist/flows/core/expression/evaluate.d.ts +22 -0
  16. package/dist/flows/core/expression/evaluate.js +141 -0
  17. package/dist/flows/core/expression/help.d.ts +25 -0
  18. package/dist/flows/core/expression/help.js +75 -0
  19. package/dist/flows/core/expression/index.d.ts +5 -0
  20. package/dist/flows/core/expression/index.js +5 -0
  21. package/dist/flows/core/expression/parse.d.ts +61 -0
  22. package/dist/flows/core/expression/parse.js +317 -0
  23. package/dist/flows/core/expression/scope.d.ts +7 -0
  24. package/dist/flows/core/expression/scope.js +25 -0
  25. package/dist/flows/core/graph.d.ts +11 -0
  26. package/dist/flows/core/graph.js +65 -0
  27. package/dist/flows/core/index.d.ts +16 -0
  28. package/dist/flows/core/index.js +16 -0
  29. package/dist/flows/core/keys.d.ts +8 -0
  30. package/dist/flows/core/keys.js +46 -0
  31. package/dist/flows/core/nodes/api.d.ts +9 -0
  32. package/dist/flows/core/nodes/api.js +12 -0
  33. package/dist/flows/core/nodes/condition.d.ts +7 -0
  34. package/dist/flows/core/nodes/condition.js +21 -0
  35. package/dist/flows/core/nodes/database.d.ts +9 -0
  36. package/dist/flows/core/nodes/database.js +12 -0
  37. package/dist/flows/core/nodes/define.d.ts +128 -0
  38. package/dist/flows/core/nodes/define.js +21 -0
  39. package/dist/flows/core/nodes/email.d.ts +9 -0
  40. package/dist/flows/core/nodes/email.js +12 -0
  41. package/dist/flows/core/nodes/end.d.ts +1 -0
  42. package/dist/flows/core/nodes/end.js +7 -0
  43. package/dist/flows/core/nodes/group.d.ts +5 -0
  44. package/dist/flows/core/nodes/group.js +18 -0
  45. package/dist/flows/core/nodes/index.d.ts +107 -0
  46. package/dist/flows/core/nodes/index.js +81 -0
  47. package/dist/flows/core/nodes/parallel.d.ts +1 -0
  48. package/dist/flows/core/nodes/parallel.js +11 -0
  49. package/dist/flows/core/nodes/path.d.ts +5 -0
  50. package/dist/flows/core/nodes/path.js +21 -0
  51. package/dist/flows/core/nodes/script.d.ts +9 -0
  52. package/dist/flows/core/nodes/script.js +12 -0
  53. package/dist/flows/core/nodes/set-variable.d.ts +13 -0
  54. package/dist/flows/core/nodes/set-variable.js +39 -0
  55. package/dist/flows/core/nodes/start.d.ts +1 -0
  56. package/dist/flows/core/nodes/start.js +7 -0
  57. package/dist/flows/core/nodes/test.d.ts +1 -0
  58. package/dist/flows/core/nodes/test.js +18 -0
  59. package/dist/flows/core/nodes/wait.d.ts +15 -0
  60. package/dist/flows/core/nodes/wait.js +18 -0
  61. package/dist/flows/core/schema.d.ts +55 -0
  62. package/dist/flows/core/schema.js +33 -0
  63. package/dist/flows/run/events.d.ts +43 -0
  64. package/dist/flows/run/events.js +1 -0
  65. package/dist/flows/run/files.d.ts +12 -0
  66. package/dist/flows/run/files.js +54 -0
  67. package/dist/flows/run/handlers/condition.d.ts +2 -0
  68. package/dist/flows/run/handlers/condition.js +17 -0
  69. package/dist/flows/run/handlers/end.d.ts +2 -0
  70. package/dist/flows/run/handlers/end.js +1 -0
  71. package/dist/flows/run/handlers/group.d.ts +2 -0
  72. package/dist/flows/run/handlers/group.js +7 -0
  73. package/dist/flows/run/handlers/index.d.ts +6 -0
  74. package/dist/flows/run/handlers/index.js +22 -0
  75. package/dist/flows/run/handlers/parallel.d.ts +3 -0
  76. package/dist/flows/run/handlers/parallel.js +2 -0
  77. package/dist/flows/run/handlers/set-variable.d.ts +2 -0
  78. package/dist/flows/run/handlers/set-variable.js +22 -0
  79. package/dist/flows/run/handlers/start.d.ts +2 -0
  80. package/dist/flows/run/handlers/start.js +1 -0
  81. package/dist/flows/run/handlers/support.d.ts +26 -0
  82. package/dist/flows/run/handlers/support.js +22 -0
  83. package/dist/flows/run/handlers/test.d.ts +2 -0
  84. package/dist/flows/run/handlers/test.js +7 -0
  85. package/dist/flows/run/handlers/wait.d.ts +2 -0
  86. package/dist/flows/run/handlers/wait.js +10 -0
  87. package/dist/flows/run/index.d.ts +8 -0
  88. package/dist/flows/run/index.js +7 -0
  89. package/dist/flows/run/project.d.ts +43 -0
  90. package/dist/flows/run/project.js +163 -0
  91. package/dist/flows/run/report.d.ts +39 -0
  92. package/dist/flows/run/report.js +46 -0
  93. package/dist/flows/run/run.d.ts +42 -0
  94. package/dist/flows/run/run.js +244 -0
  95. package/dist/locator/locator.d.ts +8 -0
  96. package/dist/locator/locator.js +46 -0
  97. package/dist/recorder/capture.d.ts +2 -0
  98. package/dist/recorder/capture.js +14 -0
  99. package/dist/recorder/normalise.d.ts +5 -0
  100. package/dist/recorder/normalise.js +12 -0
  101. package/dist/recorder/recorder.js +20 -18
  102. package/dist/runner/groups.d.ts +39 -0
  103. package/dist/runner/groups.js +58 -0
  104. package/dist/runner/run.d.ts +4 -0
  105. package/dist/runner/run.js +12 -0
  106. package/dist/runner/test-runner.d.ts +12 -2
  107. package/dist/runner/test-runner.js +36 -6
  108. package/dist/steps/emit.js +2 -0
  109. package/dist/steps/model.d.ts +6 -0
  110. package/dist/steps/parse.js +4 -0
  111. package/package.json +9 -1
@@ -0,0 +1,202 @@
1
+ import { problemOf, scopeAt } from "./expression/index.js";
2
+ import { KEY_PATTERN } from "./keys.js";
3
+ import { branchNames, dynamicOf, getPath, nodeDef, withDefaults, } from "./nodes/index.js";
4
+ /** What the node is called in a message: its title, else its key, else its kind's label */
5
+ const titleOf = (node, def) => node.title || node.key || def?.label || node.kind;
6
+ const defOf = (kind) => {
7
+ try {
8
+ return nodeDef(kind);
9
+ }
10
+ catch {
11
+ return undefined;
12
+ }
13
+ };
14
+ /** Node ids in the order a run reaches them: from Start, following edges */
15
+ function reachableFrom(flow, startId) {
16
+ const out = new Map();
17
+ for (const e of flow.edges)
18
+ out.set(e.source, [...(out.get(e.source) ?? []), e.target]);
19
+ const known = new Set(flow.nodes.map((n) => n.id));
20
+ const seen = new Set();
21
+ const visit = (id) => {
22
+ if (seen.has(id) || !known.has(id))
23
+ return;
24
+ seen.add(id);
25
+ for (const next of out.get(id) ?? [])
26
+ visit(next);
27
+ };
28
+ if (startId)
29
+ visit(startId);
30
+ return seen;
31
+ }
32
+ /**
33
+ * Checks a flow before a run: the shape of the graph, keys, references that still
34
+ * exist, expressions, and what a run can't execute. Pure; returns every problem.
35
+ * Messages are the SAMURAI app's, except that a node is named by its `title`, else
36
+ * its key, else its kind's label (the app names it by what the editor shows).
37
+ */
38
+ export function checkFlow(flow, options = {}) {
39
+ const problems = [];
40
+ const tests = options.tests ? new Set(options.tests) : undefined;
41
+ const groups = options.groups ? new Set(options.groups) : undefined;
42
+ const shapeOf = (n) => defOf(n.kind)?.shape;
43
+ for (const n of flow.nodes)
44
+ if (!defOf(n.kind))
45
+ problems.push({
46
+ level: "error",
47
+ nodeId: n.id,
48
+ message: `Unknown node kind: ${n.kind}`,
49
+ });
50
+ const starts = flow.nodes.filter((n) => shapeOf(n) === "start");
51
+ const ends = flow.nodes.filter((n) => shapeOf(n) === "end");
52
+ if (starts.length !== 1)
53
+ problems.push({ level: "error", message: "A flow has exactly one Start." });
54
+ if (ends.length !== 1)
55
+ problems.push({ level: "error", message: "A flow has exactly one End." });
56
+ const reachable = reachableFrom(flow, starts[0]?.id);
57
+ // Can reach End: walk edges backwards from End
58
+ const back = new Map();
59
+ for (const e of flow.edges)
60
+ back.set(e.target, [...(back.get(e.target) ?? []), e.source]);
61
+ const toEnd = new Set();
62
+ const walk = (id) => {
63
+ if (toEnd.has(id))
64
+ return;
65
+ toEnd.add(id);
66
+ (back.get(id) ?? []).forEach(walk);
67
+ };
68
+ ends.forEach((e) => walk(e.id));
69
+ for (const n of flow.nodes) {
70
+ const def = defOf(n.kind);
71
+ const title = options.titles?.(n) ?? titleOf(n, def);
72
+ if (!reachable.has(n.id))
73
+ problems.push({
74
+ level: "error",
75
+ nodeId: n.id,
76
+ message: `${title} isn't reachable from Start.`,
77
+ });
78
+ else if (!toEnd.has(n.id))
79
+ problems.push({
80
+ level: "error",
81
+ nodeId: n.id,
82
+ message: `${title} doesn't lead to End.`,
83
+ });
84
+ if (!def)
85
+ continue;
86
+ const names = branchNames(n);
87
+ const outCount = flow.edges.filter((e) => e.source === n.id).length;
88
+ if (dynamicOf(n) && outCount < names.length)
89
+ problems.push({
90
+ level: "error",
91
+ nodeId: n.id,
92
+ message: `${title} needs at least ${names.length} branches.`,
93
+ });
94
+ else if (!dynamicOf(n) &&
95
+ names.length &&
96
+ !names.every((h) => flow.edges.some((e) => e.source === n.id && e.sourceHandle === h)))
97
+ problems.push({
98
+ level: "error",
99
+ nodeId: n.id,
100
+ message: names.length === 2
101
+ ? `${title} needs both a ${names[0]} and a ${names[1]} branch.`
102
+ : `${title} needs a branch for each of ${names.join(", ")}.`,
103
+ });
104
+ const ctx = { label: def.label, tests, groups };
105
+ problems.push(...(def.validate?.(withDefaults(def, n), ctx) ?? []));
106
+ if (options.handled &&
107
+ (def.shape === "step" || def.shape === "branches") &&
108
+ !options.handled.has(n.kind)) {
109
+ problems.push(options.unsupported === "fail"
110
+ ? {
111
+ level: "error",
112
+ nodeId: n.id,
113
+ message: `${def.label} can't run yet.`,
114
+ }
115
+ : {
116
+ level: "warning",
117
+ nodeId: n.id,
118
+ message: `${def.label} nodes can't run yet. They are skipped.`,
119
+ });
120
+ }
121
+ }
122
+ const seen = new Map();
123
+ for (const n of flow.nodes) {
124
+ if (!n.key)
125
+ continue;
126
+ if (!KEY_PATTERN.test(n.key))
127
+ problems.push({
128
+ level: "error",
129
+ nodeId: n.id,
130
+ message: `"${n.key}" isn't a valid key. Use lowercase letters, digits and _, starting with a letter.`,
131
+ });
132
+ else if (seen.has(n.key))
133
+ problems.push({
134
+ level: "error",
135
+ nodeId: n.id,
136
+ message: `The key "${n.key}" is used by another node.`,
137
+ });
138
+ else
139
+ seen.set(n.key, n.id);
140
+ }
141
+ problems.push(...emptyExpressions(flow));
142
+ if (options.environment)
143
+ problems.push(...expressionProblems(flow, options.environment));
144
+ const nodeCount = flow.nodes.filter((n) => {
145
+ const s = shapeOf(n);
146
+ return s !== "start" && s !== "end";
147
+ }).length;
148
+ if (nodeCount > 0 &&
149
+ flow.edges.some((e) => shapeOf(flow.nodes.find((n) => n.id === e.source) ?? {}) ===
150
+ "start" &&
151
+ shapeOf(flow.nodes.find((n) => n.id === e.target) ?? {}) ===
152
+ "end"))
153
+ problems.push({
154
+ level: "error",
155
+ message: "Start connects straight to End, skipping the nodes in between.",
156
+ });
157
+ return problems;
158
+ }
159
+ /** Every expression field left empty: it would stop the run at its node */
160
+ function emptyExpressions(flow) {
161
+ return flow.nodes.flatMap((n) => {
162
+ const def = defOf(n.kind);
163
+ if (!def)
164
+ return [];
165
+ return def.fields
166
+ .filter((f) => f.type === "expression")
167
+ .filter((f) => {
168
+ const text = getPath(n, f.path);
169
+ return (text === undefined || (typeof text === "string" && text.trim() === ""));
170
+ })
171
+ .map(() => ({
172
+ level: "error",
173
+ nodeId: n.id,
174
+ message: `${def.label}: Write an expression`,
175
+ }));
176
+ });
177
+ }
178
+ /** Every expression field that doesn't check out, as "Label: message" */
179
+ export function expressionProblems(flow, environment) {
180
+ return flow.nodes.flatMap((n) => {
181
+ const def = defOf(n.kind);
182
+ if (!def)
183
+ return [];
184
+ return def.fields
185
+ .filter((f) => f.type === "expression")
186
+ .flatMap((f) => {
187
+ const text = getPath(n, f.path);
188
+ if (typeof text !== "string" || text.trim() === "")
189
+ return [];
190
+ const problem = problemOf(text, scopeAt(flow, n.id, environment));
191
+ return problem
192
+ ? [
193
+ {
194
+ level: "error",
195
+ nodeId: n.id,
196
+ message: `${def.label}: ${problem.message}`,
197
+ },
198
+ ]
199
+ : [];
200
+ });
201
+ });
202
+ }
@@ -0,0 +1,21 @@
1
+ import { type ExpressionError, type Parsed } from "./parse.js";
2
+ export interface ExpressionScope {
3
+ /** The flow environment's name and its variable names */
4
+ env: {
5
+ name: string;
6
+ variables: string[];
7
+ };
8
+ /** Variables set before this node, on every path */
9
+ vars: string[];
10
+ /** Keys of nodes before this node (on some path) */
11
+ before: string[];
12
+ /** Keys of nodes after this node */
13
+ after: string[];
14
+ /** Every key in the flow */
15
+ all: string[];
16
+ }
17
+ export declare const NODE_FIELDS: readonly ["status", "durationMs", "error"];
18
+ export declare const RUN_FIELDS: readonly ["environment", "trigger", "startedAt"];
19
+ export declare function checkExpression(parsed: Parsed, scope: ExpressionScope): ExpressionError | undefined;
20
+ /** Parse + check in one: the first problem, if any */
21
+ export declare const problemOf: (text: string, scope: ExpressionScope) => ExpressionError | undefined;
@@ -0,0 +1,95 @@
1
+ import { HELPER_ARGS, HELPERS } from "./evaluate.js";
2
+ import { parseExpression } from "./parse.js";
3
+ export const NODE_FIELDS = ["status", "durationMs", "error"];
4
+ export const RUN_FIELDS = ["environment", "trigger", "startedAt"];
5
+ const list = (items) => items.length <= 1
6
+ ? items.join("")
7
+ : `${items.slice(0, -1).join(", ")} or ${items[items.length - 1]}`;
8
+ function distance(a, b) {
9
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [
10
+ i,
11
+ ...Array(b.length).fill(0),
12
+ ]);
13
+ for (let j = 1; j <= b.length; j++)
14
+ d[0][j] = j;
15
+ for (let i = 1; i <= a.length; i++)
16
+ for (let j = 1; j <= b.length; j++)
17
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
18
+ return d[a.length][b.length];
19
+ }
20
+ /** The closest name, when it's a likely typo */
21
+ const closest = (name, options) => options.find((o) => o.toLowerCase() === name.toLowerCase()) ??
22
+ options
23
+ .filter((o) => distance(o, name) <= 2)
24
+ .sort((a, b) => distance(a, name) - distance(b, name))[0];
25
+ export function checkExpression(parsed, scope) {
26
+ if (!parsed.ok)
27
+ return parsed.error;
28
+ for (const call of parsed.calls) {
29
+ if (!HELPERS.includes(call.name)) {
30
+ const near = closest(call.name, HELPERS);
31
+ return {
32
+ message: near
33
+ ? `${call.name}() doesn't exist. Did you mean ${near}()?`
34
+ : `${call.name}() doesn't exist. Use ${list(HELPERS)}.`,
35
+ at: call.start,
36
+ };
37
+ }
38
+ const want = HELPER_ARGS[call.name];
39
+ if (call.args !== want)
40
+ return {
41
+ message: `${call.name}() takes ${want} ${want === 1 ? "value" : "values"}`,
42
+ at: call.start,
43
+ };
44
+ }
45
+ for (const { path, start: at } of parsed.refs) {
46
+ const [root, name, field] = path;
47
+ if (!["env", "vars", "nodes", "run"].includes(root))
48
+ return {
49
+ message: `${root} isn't available. Use env., vars., nodes. or run.`,
50
+ at,
51
+ };
52
+ if (name === undefined)
53
+ return {
54
+ message: `Use ${root}.<${root === "nodes" ? "key" : root === "run" ? "field" : "name"}>`,
55
+ at,
56
+ };
57
+ if (root === "env" && !scope.env.variables.includes(name))
58
+ return { message: `${name} isn't a variable of ${scope.env.name}`, at };
59
+ if (root === "vars" && !scope.vars.includes(name))
60
+ return { message: `vars.${name} isn't set before this node`, at };
61
+ if (root === "run" && !RUN_FIELDS.includes(name))
62
+ return {
63
+ message: `run.${name} doesn't exist. Use ${list(RUN_FIELDS)}`,
64
+ at,
65
+ };
66
+ if (root === "nodes") {
67
+ if (!scope.all.includes(name)) {
68
+ const near = closest(name, scope.all);
69
+ return {
70
+ message: `No node with key "${name}"${near ? `. Did you mean ${near}?` : ""}`,
71
+ at,
72
+ };
73
+ }
74
+ if (scope.after.includes(name))
75
+ return {
76
+ message: `${name} runs after this node, so it has no result yet`,
77
+ at,
78
+ };
79
+ if (!scope.before.includes(name))
80
+ return {
81
+ message: `${name} is on another branch, so it has no result here`,
82
+ at,
83
+ };
84
+ if (field !== undefined &&
85
+ !NODE_FIELDS.includes(field))
86
+ return {
87
+ message: `nodes.${name}.${field} doesn't exist. Use ${list(NODE_FIELDS)}`,
88
+ at,
89
+ };
90
+ }
91
+ }
92
+ return undefined;
93
+ }
94
+ /** Parse + check in one: the first problem, if any */
95
+ export const problemOf = (text, scope) => checkExpression(parseExpression(text), scope);
@@ -0,0 +1,22 @@
1
+ import { type Expr } from "./parse.js";
2
+ export interface ExpressionContext {
3
+ env: Record<string, unknown>;
4
+ vars: Record<string, unknown>;
5
+ nodes: Record<string, {
6
+ status: string;
7
+ durationMs?: number;
8
+ error?: string;
9
+ }>;
10
+ run: {
11
+ environment: string;
12
+ trigger: string;
13
+ startedAt: string;
14
+ };
15
+ }
16
+ export declare const HELPERS: readonly ["contains", "startsWith", "matches", "len", "exists"];
17
+ export declare const HELPER_ARGS: Record<(typeof HELPERS)[number], number>;
18
+ export declare class EvaluationError extends Error {
19
+ }
20
+ export declare function evaluate(expr: Expr, context: ExpressionContext): unknown;
21
+ /** Parses and evaluates; a syntax error throws an EvaluationError with the parser's message */
22
+ export declare function evaluateText(text: string, context: ExpressionContext): unknown;
@@ -0,0 +1,141 @@
1
+ import { parseExpression } from "./parse.js";
2
+ export const HELPERS = [
3
+ "contains",
4
+ "startsWith",
5
+ "matches",
6
+ "len",
7
+ "exists",
8
+ ];
9
+ export const HELPER_ARGS = {
10
+ contains: 2,
11
+ startsWith: 2,
12
+ matches: 2,
13
+ len: 1,
14
+ exists: 1,
15
+ };
16
+ export class EvaluationError extends Error {
17
+ }
18
+ const ROOTS = ["env", "vars", "nodes", "run"];
19
+ /** Own, plain values only: nothing from prototypes, nothing named like an internal */
20
+ function read(object, key) {
21
+ if (object === null || typeof object !== "object")
22
+ return undefined;
23
+ const name = String(key);
24
+ if (name.startsWith("__") || name === "constructor" || name === "prototype")
25
+ return undefined;
26
+ if (Array.isArray(object))
27
+ return /^\d+$/.test(name) ? object[Number(name)] : undefined;
28
+ return Object.prototype.hasOwnProperty.call(object, name)
29
+ ? object[name]
30
+ : undefined;
31
+ }
32
+ const typeName = (v) => v === null ? "null" : Array.isArray(v) ? "a list" : typeof v;
33
+ function arithmetic(op, l, r) {
34
+ if (op === "+" && (typeof l === "string" || typeof r === "string")) {
35
+ const ok = (v) => typeof v === "string" || typeof v === "number";
36
+ if (ok(l) && ok(r))
37
+ return String(l) + String(r);
38
+ }
39
+ if (typeof l !== "number" || typeof r !== "number")
40
+ throw new EvaluationError(`Can't use ${op} on ${typeName(typeof l !== "number" ? l : r)}`);
41
+ switch (op) {
42
+ case "+":
43
+ return l + r;
44
+ case "-":
45
+ return l - r;
46
+ case "*":
47
+ return l * r;
48
+ case "/":
49
+ return l / r;
50
+ default:
51
+ return l % r;
52
+ }
53
+ }
54
+ function compare(op, l, r) {
55
+ const same = (typeof l === "number" && typeof r === "number") ||
56
+ (typeof l === "string" && typeof r === "string");
57
+ if (!same)
58
+ return false;
59
+ const a = l;
60
+ const b = r;
61
+ return op === "<"
62
+ ? a < b
63
+ : op === "<="
64
+ ? a <= b
65
+ : op === ">"
66
+ ? a > b
67
+ : a >= b;
68
+ }
69
+ function makeRegExp(pattern, flags) {
70
+ try {
71
+ return new RegExp(pattern, flags);
72
+ }
73
+ catch (e) {
74
+ throw new EvaluationError(`Invalid pattern: ${e instanceof Error ? e.message : String(e)}`);
75
+ }
76
+ }
77
+ const helpers = {
78
+ contains: (x, y) => typeof x === "string"
79
+ ? typeof y === "string" && x.includes(y)
80
+ : Array.isArray(x) && x.includes(y),
81
+ startsWith: (s, p) => typeof s === "string" && typeof p === "string" && s.startsWith(p),
82
+ matches: (s, re) => typeof s === "string" &&
83
+ (re instanceof RegExp
84
+ ? re.test(s)
85
+ : typeof re === "string" && makeRegExp(re).test(s)),
86
+ len: (x) => typeof x === "string" || Array.isArray(x)
87
+ ? x.length
88
+ : x && typeof x === "object"
89
+ ? Object.keys(x).length
90
+ : 0,
91
+ exists: (x) => x !== undefined && x !== null,
92
+ };
93
+ export function evaluate(expr, context) {
94
+ const go = (e) => {
95
+ switch (e.type) {
96
+ case "literal":
97
+ return e.value;
98
+ case "regex":
99
+ return makeRegExp(e.pattern, e.flags);
100
+ case "name":
101
+ if (!ROOTS.includes(e.name))
102
+ throw new EvaluationError(`${e.name} isn't available. Use env., vars., nodes. or run.`);
103
+ return context[e.name];
104
+ case "member":
105
+ return read(go(e.object), typeof e.property === "string" ? e.property : go(e.property));
106
+ case "unary":
107
+ return e.op === "!" ? !go(e.arg) : arithmetic("-", 0, go(e.arg));
108
+ case "binary": {
109
+ if (e.op === "&&")
110
+ return go(e.left) ? Boolean(go(e.right)) : false;
111
+ if (e.op === "||")
112
+ return go(e.left) ? true : Boolean(go(e.right));
113
+ const l = go(e.left);
114
+ const r = go(e.right);
115
+ if (e.op === "==")
116
+ return l === r;
117
+ if (e.op === "!=")
118
+ return l !== r;
119
+ if (["<", "<=", ">", ">="].includes(e.op))
120
+ return compare(e.op, l, r);
121
+ return arithmetic(e.op, l, r);
122
+ }
123
+ case "call": {
124
+ const fn = Object.prototype.hasOwnProperty.call(helpers, e.callee)
125
+ ? helpers[e.callee]
126
+ : undefined;
127
+ if (!fn)
128
+ throw new EvaluationError(`${e.callee}() doesn't exist`);
129
+ return fn(...e.args.map(go));
130
+ }
131
+ }
132
+ };
133
+ return go(expr);
134
+ }
135
+ /** Parses and evaluates; a syntax error throws an EvaluationError with the parser's message */
136
+ export function evaluateText(text, context) {
137
+ const parsed = parseExpression(text);
138
+ if (!parsed.ok)
139
+ throw new EvaluationError(parsed.error.message);
140
+ return evaluate(parsed.expr, context);
141
+ }
@@ -0,0 +1,25 @@
1
+ import { type ExpressionScope } from "./check.js";
2
+ export interface HelpEntry {
3
+ label: string;
4
+ insert: string;
5
+ detail?: string;
6
+ }
7
+ export interface HelpSections {
8
+ nodes: HelpEntry[];
9
+ vars: HelpEntry[];
10
+ env: HelpEntry[];
11
+ run: HelpEntry[];
12
+ }
13
+ /** What an expression at a node can use, as entries to insert; the same names the checker accepts */
14
+ export declare function helpEntries(scope: ExpressionScope): HelpSections;
15
+ /** Inserts text at the cursor; returns the new value and cursor */
16
+ export declare function insertAt(value: string, cursor: number, text: string): {
17
+ value: string;
18
+ cursor: number;
19
+ };
20
+ export declare const HELPER_DOCS: {
21
+ name: string;
22
+ signature: string;
23
+ example: string;
24
+ description: string;
25
+ }[];
@@ -0,0 +1,75 @@
1
+ import { NODE_FIELDS, RUN_FIELDS } from "./check.js";
2
+ const access = (root, name) => /^[A-Za-z_$][\w$]*$/.test(name) ? `${root}.${name}` : `${root}["${name}"]`;
3
+ const NODE_DETAIL = {
4
+ status: '"passed", "failed" or "skipped"',
5
+ durationMs: "how long it took, in ms",
6
+ error: "its error message, when it failed",
7
+ };
8
+ const RUN_DETAIL = {
9
+ environment: "the environment this run uses",
10
+ trigger: '"manual"',
11
+ startedAt: "when the run started (ISO time)",
12
+ };
13
+ /** What an expression at a node can use, as entries to insert; the same names the checker accepts */
14
+ export function helpEntries(scope) {
15
+ return {
16
+ nodes: scope.before.flatMap((key) => NODE_FIELDS.map((f) => ({
17
+ label: `${key}.${f}`,
18
+ insert: `nodes.${key}.${f}`,
19
+ detail: NODE_DETAIL[f],
20
+ }))),
21
+ vars: scope.vars.map((name) => ({
22
+ label: name,
23
+ insert: access("vars", name),
24
+ })),
25
+ env: scope.env.variables.map((name) => ({
26
+ label: name,
27
+ insert: access("env", name),
28
+ detail: scope.env.name,
29
+ })),
30
+ run: RUN_FIELDS.map((f) => ({
31
+ label: f,
32
+ insert: `run.${f}`,
33
+ detail: RUN_DETAIL[f],
34
+ })),
35
+ };
36
+ }
37
+ /** Inserts text at the cursor; returns the new value and cursor */
38
+ export function insertAt(value, cursor, text) {
39
+ return {
40
+ value: value.slice(0, cursor) + text + value.slice(cursor),
41
+ cursor: cursor + text.length,
42
+ };
43
+ }
44
+ export const HELPER_DOCS = [
45
+ {
46
+ name: "contains",
47
+ signature: "contains(list or text, value)",
48
+ example: 'contains(vars.tags, "vip")',
49
+ description: "Whether a list has the value, or text contains it",
50
+ },
51
+ {
52
+ name: "startsWith",
53
+ signature: "startsWith(text, start)",
54
+ example: 'startsWith(run.environment, "prod")',
55
+ description: "Whether text starts with the given text",
56
+ },
57
+ {
58
+ name: "matches",
59
+ signature: "matches(text, /pattern/)",
60
+ example: "matches(env.baseURL, /^https:/)",
61
+ description: "Whether text matches a regular expression",
62
+ },
63
+ {
64
+ name: "len",
65
+ signature: "len(list or text)",
66
+ example: "len(vars.items) > 0",
67
+ description: "How many items a list has, or characters a text",
68
+ },
69
+ {
70
+ name: "exists",
71
+ signature: "exists(value)",
72
+ example: "exists(vars.orderId)",
73
+ description: "Whether a value is set",
74
+ },
75
+ ];
@@ -0,0 +1,5 @@
1
+ export * from "./parse.js";
2
+ export * from "./evaluate.js";
3
+ export * from "./check.js";
4
+ export * from "./scope.js";
5
+ export * from "./help.js";
@@ -0,0 +1,5 @@
1
+ export * from "./parse.js";
2
+ export * from "./evaluate.js";
3
+ export * from "./check.js";
4
+ export * from "./scope.js";
5
+ export * from "./help.js";
@@ -0,0 +1,61 @@
1
+ export type BinaryOp = "==" | "!=" | "<" | "<=" | ">" | ">=" | "&&" | "||" | "+" | "-" | "*" | "/" | "%";
2
+ export type Expr = {
3
+ type: "literal";
4
+ value: string | number | boolean | null;
5
+ start: number;
6
+ } | {
7
+ type: "regex";
8
+ pattern: string;
9
+ flags: string;
10
+ start: number;
11
+ } | {
12
+ type: "name";
13
+ name: string;
14
+ start: number;
15
+ } | {
16
+ type: "member";
17
+ object: Expr;
18
+ property: string | Expr;
19
+ start: number;
20
+ } | {
21
+ type: "unary";
22
+ op: "!" | "-";
23
+ arg: Expr;
24
+ start: number;
25
+ } | {
26
+ type: "binary";
27
+ op: BinaryOp;
28
+ left: Expr;
29
+ right: Expr;
30
+ start: number;
31
+ } | {
32
+ type: "call";
33
+ callee: string;
34
+ args: Expr[];
35
+ start: number;
36
+ };
37
+ /** A name chain with static parts: nodes.login.status → ["nodes", "login", "status"] */
38
+ export interface Reference {
39
+ path: string[];
40
+ start: number;
41
+ }
42
+ export interface CallRef {
43
+ name: string;
44
+ args: number;
45
+ start: number;
46
+ }
47
+ export interface ExpressionError {
48
+ message: string;
49
+ at: number;
50
+ }
51
+ export type Parsed = {
52
+ ok: true;
53
+ expr: Expr;
54
+ refs: Reference[];
55
+ calls: CallRef[];
56
+ } | {
57
+ ok: false;
58
+ error: ExpressionError;
59
+ };
60
+ /** Parses an expression into its syntax tree, the references it reads and the helpers it calls */
61
+ export declare function parseExpression(text: string): Parsed;