@venn-lang/runtime 0.1.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.
- package/LICENSE +21 -0
- package/README.md +158 -0
- package/dist/index.d.ts +453 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3314 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/check/check-calls.ts +82 -0
- package/src/check/check-deco-body.ts +77 -0
- package/src/check/check-document.ts +106 -0
- package/src/check/check-env.ts +96 -0
- package/src/check/check-fragment-call.ts +27 -0
- package/src/check/check-imports.ts +82 -0
- package/src/check/check-interpolation.ts +58 -0
- package/src/check/check-options.ts +18 -0
- package/src/check/check-uncalled.ts +42 -0
- package/src/check/check.types.ts +30 -0
- package/src/check/index.ts +4 -0
- package/src/context/action-context.ts +20 -0
- package/src/context/index.ts +1 -0
- package/src/decorators/builtin-decorators.ts +87 -0
- package/src/decorators/create-decorator-source.ts +25 -0
- package/src/decorators/index.ts +2 -0
- package/src/emit/create-emitter.ts +16 -0
- package/src/emit/emitter.types.ts +6 -0
- package/src/emit/index.ts +3 -0
- package/src/emit/run-id.ts +11 -0
- package/src/eventsink/event-sink.port.ts +13 -0
- package/src/eventsink/event-sink.types.ts +11 -0
- package/src/eventsink/index.ts +4 -0
- package/src/eventsink/memory-sink.ts +17 -0
- package/src/eventsink/ndjson-sink.ts +14 -0
- package/src/index.ts +14 -0
- package/src/ports/create-port-resolver.ts +39 -0
- package/src/ports/index.ts +2 -0
- package/src/ports/port-resolver.types.ts +12 -0
- package/src/registry/build-registry.ts +76 -0
- package/src/registry/index.ts +2 -0
- package/src/registry/registry.types.ts +24 -0
- package/src/run/create-runner.ts +103 -0
- package/src/run/index.ts +4 -0
- package/src/run/resolve-imports.ts +166 -0
- package/src/run/runner.types.ts +67 -0
- package/src/scheduler/absorb-exit.ts +19 -0
- package/src/scheduler/aliases.ts +44 -0
- package/src/scheduler/annotations.ts +48 -0
- package/src/scheduler/base-scope.ts +25 -0
- package/src/scheduler/bind-globals.ts +55 -0
- package/src/scheduler/bind-imports.ts +130 -0
- package/src/scheduler/bind-namespaces.ts +61 -0
- package/src/scheduler/bind-prelude.ts +11 -0
- package/src/scheduler/block-plan.ts +66 -0
- package/src/scheduler/branch-engine.ts +13 -0
- package/src/scheduler/call-params.ts +140 -0
- package/src/scheduler/cleanup.types.ts +18 -0
- package/src/scheduler/collect.ts +60 -0
- package/src/scheduler/concurrency.ts +16 -0
- package/src/scheduler/create-cleanup-list.ts +17 -0
- package/src/scheduler/declared-arity.ts +13 -0
- package/src/scheduler/engine.types.ts +50 -0
- package/src/scheduler/filter.ts +5 -0
- package/src/scheduler/filter.types.ts +9 -0
- package/src/scheduler/flaky.ts +32 -0
- package/src/scheduler/flaky.types.ts +7 -0
- package/src/scheduler/index.ts +16 -0
- package/src/scheduler/invocation.ts +103 -0
- package/src/scheduler/local-call.ts +28 -0
- package/src/scheduler/node-span.ts +29 -0
- package/src/scheduler/opts-text.ts +10 -0
- package/src/scheduler/opts.ts +14 -0
- package/src/scheduler/pending.types.ts +9 -0
- package/src/scheduler/run-action.ts +163 -0
- package/src/scheduler/run-around.ts +23 -0
- package/src/scheduler/run-attempts.ts +105 -0
- package/src/scheduler/run-bindings.ts +67 -0
- package/src/scheduler/run-block.ts +65 -0
- package/src/scheduler/run-document.ts +181 -0
- package/src/scheduler/run-expect.ts +48 -0
- package/src/scheduler/run-flow.ts +69 -0
- package/src/scheduler/run-foreach.ts +148 -0
- package/src/scheduler/run-group.ts +9 -0
- package/src/scheduler/run-if.ts +21 -0
- package/src/scheduler/run-lifecycle.ts +86 -0
- package/src/scheduler/run-matcher.ts +99 -0
- package/src/scheduler/run-parallel.ts +68 -0
- package/src/scheduler/run-prologue.ts +59 -0
- package/src/scheduler/run-race.ts +20 -0
- package/src/scheduler/run-repeat.ts +54 -0
- package/src/scheduler/run-run.ts +41 -0
- package/src/scheduler/run-script.ts +48 -0
- package/src/scheduler/run-statements.ts +107 -0
- package/src/scheduler/run-step.ts +76 -0
- package/src/scheduler/run-try.ts +34 -0
- package/src/scheduler/run-while.ts +50 -0
- package/src/scheduler/script-lifecycle.ts +47 -0
- package/src/scheduler/settled.ts +20 -0
- package/src/scheduler/signals.ts +33 -0
- package/src/scheduler/target.ts +30 -0
- package/src/scheduler/unknown-option.ts +87 -0
- package/src/scope/create-scope.ts +67 -0
- package/src/scope/index.ts +2 -0
- package/src/scope/scope.types.ts +12 -0
- package/src/types/create-type-catalog.ts +70 -0
- package/src/types/index.ts +1 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3314 @@
|
|
|
1
|
+
import { CODES, PRELUDE_VALUES, ProblemError, buildDiff, buildProblem, callArgs, closureOfDecl, compileExpr, decoCannotCall, decorateCallable, display, evaluate, expand, invoke, isActionCall, isBlock, isCall, isCallable, isCaptureStmt, isConfigDecl, isDecoDecl, isFlowDecl, isFnDecl, isFragmentDecl, isIfStmt, isLetStmt, isLifecycleDecl, isMatcherClause, isMatrixDecl, isMember, isNamespaceValue, isPackageSpecifier, isRef, isRunStmt, isRunnable, isStepDecl, isStringLit, isUseDecl, isValueImport, memberValue, namespaceValue, nativeFn, parse, parseExpression, prune, readDecorations, readMeta, scanInterpolations, specToType, splitCall, truthy, typeName, walkAst } from "@venn-lang/core";
|
|
2
|
+
import { ConsolePort, VennError, bindPort, missingCapabilities } from "@venn-lang/contracts";
|
|
3
|
+
import { paramSpecs } from "@venn-lang/sdk";
|
|
4
|
+
//#region src/scheduler/signals.ts
|
|
5
|
+
var BreakSignal = class {};
|
|
6
|
+
var ContinueSignal = class {};
|
|
7
|
+
var ReturnSignal = class {
|
|
8
|
+
value;
|
|
9
|
+
constructor(value) {
|
|
10
|
+
this.value = value;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
/** Thrown at a statement boundary when the branch's race has already been won. */
|
|
14
|
+
var CancelSignal = class {};
|
|
15
|
+
/**
|
|
16
|
+
* Thrown by `exit`, carrying the code the host should leave with.
|
|
17
|
+
*
|
|
18
|
+
* A signal and not an error: a run that exits has not failed. The code it
|
|
19
|
+
* carries is the whole verdict, and `exit 0` is a clean ending, which an error
|
|
20
|
+
* unwinding as a failure could not express.
|
|
21
|
+
*/
|
|
22
|
+
var ExitSignal = class {
|
|
23
|
+
code;
|
|
24
|
+
constructor(code) {
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function isControlSignal(value) {
|
|
29
|
+
return value instanceof BreakSignal || value instanceof ContinueSignal || value instanceof ReturnSignal || value instanceof CancelSignal || value instanceof ExitSignal;
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/scheduler/absorb-exit.ts
|
|
33
|
+
/**
|
|
34
|
+
* Run `body`, letting an `exit` end it.
|
|
35
|
+
*
|
|
36
|
+
* The exit code travels on the engine rather than on the throw, so whatever
|
|
37
|
+
* wraps this still finishes the way it always does: teardowns run, `run.finished`
|
|
38
|
+
* is emitted, and the host reads one number off the result. Any other error keeps
|
|
39
|
+
* unwinding untouched.
|
|
40
|
+
*/
|
|
41
|
+
async function absorbExit(engine, body) {
|
|
42
|
+
try {
|
|
43
|
+
await body();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (!(error instanceof ExitSignal)) throw error;
|
|
46
|
+
engine.exit = error.code;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/scheduler/aliases.ts
|
|
51
|
+
/**
|
|
52
|
+
* The namespaces this file actually brought in with `use`: the alias when one
|
|
53
|
+
* was given, otherwise the namespace the package contributes. A name outside
|
|
54
|
+
* this set was never imported, however well the registry knows it.
|
|
55
|
+
*/
|
|
56
|
+
function collectNamespaces(document, registry) {
|
|
57
|
+
const names = /* @__PURE__ */ new Set();
|
|
58
|
+
for (const decl of document.imports) {
|
|
59
|
+
if (!isUseDecl(decl)) continue;
|
|
60
|
+
const bound = decl.alias ?? registry.namespaceOf(decl.pkg);
|
|
61
|
+
if (bound) names.add(bound);
|
|
62
|
+
}
|
|
63
|
+
return names;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The names this file binds at the top level.
|
|
67
|
+
*
|
|
68
|
+
* `page.click()` reads like a namespace and a verb, and is neither: it is a
|
|
69
|
+
* method on something the file holds. The registry has no schema for it, so it
|
|
70
|
+
* is known here and verified nowhere, which beats reporting it as an unknown
|
|
71
|
+
* action it plainly is not.
|
|
72
|
+
*/
|
|
73
|
+
function collectBoundNames(document) {
|
|
74
|
+
const names = /* @__PURE__ */ new Set();
|
|
75
|
+
for (const decl of document.decls) if (isLetStmt(decl)) names.add(decl.name);
|
|
76
|
+
return names;
|
|
77
|
+
}
|
|
78
|
+
/** `use "venn/http" as h` → `{ h → "http" }`, resolved through the registry. */
|
|
79
|
+
function collectAliases(document, registry) {
|
|
80
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const decl of document.imports) {
|
|
82
|
+
if (!isUseDecl(decl) || !decl.alias) continue;
|
|
83
|
+
const namespace = registry.namespaceOf(decl.pkg);
|
|
84
|
+
if (namespace) aliases.set(decl.alias, namespace);
|
|
85
|
+
}
|
|
86
|
+
return aliases;
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
//#region src/scheduler/bind-globals.ts
|
|
90
|
+
/**
|
|
91
|
+
* Bind every top-level `fn` as a callable closure. Hoisted so a function can be
|
|
92
|
+
* called before its textual position, and so functions may reference each other
|
|
93
|
+
* regardless of order.
|
|
94
|
+
*
|
|
95
|
+
* A `deco` that asked to `wrap` one is honoured here, at the only moment the
|
|
96
|
+
* function becomes a value: the name a caller reaches is already the decorated
|
|
97
|
+
* one, so a middleware cannot be stepped around by calling from somewhere else.
|
|
98
|
+
*/
|
|
99
|
+
function bindFunctions(doc, scope) {
|
|
100
|
+
for (const decl of doc.decls) if (isFnDecl(decl)) scope.set(decl.name, decorateCallable(decl, closureOfDecl(decl, scope)));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Bind top-level functions and the plain `const`/`let` globals every flow can
|
|
104
|
+
* read without anything having run yet.
|
|
105
|
+
*
|
|
106
|
+
* Only pure values. One that calls a verb is a statement, and statements run in
|
|
107
|
+
* the prologue, in order and once, before the flows: opening a database is
|
|
108
|
+
* something a program *does*, not something a name quietly is.
|
|
109
|
+
*/
|
|
110
|
+
function bindGlobals(doc, scope) {
|
|
111
|
+
bindFunctions(doc, scope);
|
|
112
|
+
bindPlainValues(doc, scope);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The `const`/`let` globals, evaluated where they stand.
|
|
116
|
+
*
|
|
117
|
+
* Separate from the functions because the order matters across files: a function
|
|
118
|
+
* is hoisted and reads its scope when it is *called*, while a value is read now,
|
|
119
|
+
* so anything a value might depend on has to be in place first.
|
|
120
|
+
*/
|
|
121
|
+
function bindPlainValues(doc, scope) {
|
|
122
|
+
for (const decl of doc.decls) if (isLetStmt(decl) && isPlainValue(decl)) scope.set(decl.name, evaluate(decl.value, scope));
|
|
123
|
+
}
|
|
124
|
+
function isPlainValue(decl) {
|
|
125
|
+
return decl.args.length === 0 && !decl.opts;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region src/scheduler/bind-imports.ts
|
|
129
|
+
/**
|
|
130
|
+
* Bind the names a document imported, each to the value its own module made.
|
|
131
|
+
*
|
|
132
|
+
* A `pub fn` is a closure over the file it was written in (it calls that file's
|
|
133
|
+
* private helpers and reads that file's globals) so it cannot simply be lifted
|
|
134
|
+
* into the importer's scope. Each module gets a scope of its own, built the same
|
|
135
|
+
* way the entry document's is, and the importer takes only the names it asked
|
|
136
|
+
* for out of it.
|
|
137
|
+
*
|
|
138
|
+
* Three passes over every module at once, not one walk per import: two files
|
|
139
|
+
* that call each other are ordinary, and no single order resolves them. Filling
|
|
140
|
+
* every scope first and wiring afterwards has no order to get wrong, because a
|
|
141
|
+
* function captures the scope object and wiring mutates that same object.
|
|
142
|
+
*
|
|
143
|
+
* Call this before the document's own globals are bound, so a local name of the
|
|
144
|
+
* same spelling wins. That is the rule fragments already follow.
|
|
145
|
+
*/
|
|
146
|
+
function bindImports(args) {
|
|
147
|
+
const built = /* @__PURE__ */ new Map();
|
|
148
|
+
for (const [uri, module] of args.graph.modules) {
|
|
149
|
+
const scope = args.base();
|
|
150
|
+
built.set(uri, scope);
|
|
151
|
+
bindFunctions(module, scope);
|
|
152
|
+
}
|
|
153
|
+
for (const [uri, module] of args.graph.modules) wire({
|
|
154
|
+
document: module,
|
|
155
|
+
uri,
|
|
156
|
+
into: scopeAt(built, uri),
|
|
157
|
+
graph: args.graph,
|
|
158
|
+
built
|
|
159
|
+
});
|
|
160
|
+
for (const [uri, module] of args.graph.modules) bindPlainValues(module, scopeAt(built, uri));
|
|
161
|
+
wire({
|
|
162
|
+
document: args.document,
|
|
163
|
+
uri: args.uri,
|
|
164
|
+
into: args.scope,
|
|
165
|
+
graph: args.graph,
|
|
166
|
+
built
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function scopeAt(built, uri) {
|
|
170
|
+
return built.get(uri);
|
|
171
|
+
}
|
|
172
|
+
function wire(args) {
|
|
173
|
+
for (const decl of args.document.imports) {
|
|
174
|
+
if (!isValueImport(decl)) continue;
|
|
175
|
+
if (isPackageSpecifier(decl.path)) {
|
|
176
|
+
takePackage({
|
|
177
|
+
decl,
|
|
178
|
+
graph: args.graph,
|
|
179
|
+
into: args.into
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const from = args.graph.resolve(args.uri, decl.path);
|
|
184
|
+
const module = args.graph.modules.get(from);
|
|
185
|
+
const source = args.built.get(from);
|
|
186
|
+
if (module && source) take({
|
|
187
|
+
decl,
|
|
188
|
+
module,
|
|
189
|
+
source,
|
|
190
|
+
into: args.into
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The names an installed package published.
|
|
196
|
+
*
|
|
197
|
+
* Everything it exports is fair game: a package has no `pub`, its exports *are*
|
|
198
|
+
* what it made public, so unlike a `.vn` module there is nothing to filter
|
|
199
|
+
* against. `default` is bound too, under whichever name the import gave it,
|
|
200
|
+
* because that is the only name it has here.
|
|
201
|
+
*/
|
|
202
|
+
function takePackage(args) {
|
|
203
|
+
const module = args.graph.npm?.get(args.decl.path);
|
|
204
|
+
if (!module) return;
|
|
205
|
+
if (args.decl.wildcard) return void args.into.set(args.decl.wildcard, { ...module });
|
|
206
|
+
if (args.decl.default) return void args.into.set(args.decl.default, module.default);
|
|
207
|
+
for (const name of args.decl.names) if (name in module) args.into.set(name, module[name]);
|
|
208
|
+
}
|
|
209
|
+
function take(args) {
|
|
210
|
+
const exported = exportedFunctions(args.module);
|
|
211
|
+
if (args.decl.wildcard) {
|
|
212
|
+
args.into.set(args.decl.wildcard, gathered(exported, args.source));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const name of args.decl.names) if (exported.has(name)) args.into.set(name, args.source.lookup(name));
|
|
216
|
+
}
|
|
217
|
+
/** `import * as u`: one value holding everything the module published. */
|
|
218
|
+
function gathered(names, source) {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const name of names) out[name] = source.lookup(name);
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
/** The names a module made `pub` that are values a caller can hold. */
|
|
224
|
+
function exportedFunctions(document) {
|
|
225
|
+
const names = /* @__PURE__ */ new Set();
|
|
226
|
+
for (const decl of document.decls) if (isFnDecl(decl) && decl.export) names.add(decl.name);
|
|
227
|
+
return names;
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/scope/create-scope.ts
|
|
231
|
+
/**
|
|
232
|
+
* A lexical scope.
|
|
233
|
+
*
|
|
234
|
+
* A class, not an object literal: a `forEach` over 50k items builds 50k scopes,
|
|
235
|
+
* and a literal allocates a closure per method for each of them. One prototype
|
|
236
|
+
* does for all.
|
|
237
|
+
*
|
|
238
|
+
* Bindings live in cells rather than as bare values, so a compiled function can
|
|
239
|
+
* address one once and read it by index on every call after. The cell exists as
|
|
240
|
+
* soon as anyone asks for it, which is what lets a recursive `fn` work: the
|
|
241
|
+
* closure asks for its own name while being built, before the binding fills it.
|
|
242
|
+
*/
|
|
243
|
+
var MapScope = class MapScope {
|
|
244
|
+
parent;
|
|
245
|
+
vars = /* @__PURE__ */ new Map();
|
|
246
|
+
constructor(parent) {
|
|
247
|
+
this.parent = parent;
|
|
248
|
+
}
|
|
249
|
+
lookup(name) {
|
|
250
|
+
const found = this.vars.get(name);
|
|
251
|
+
return found === void 0 ? this.parent?.lookup(name) : found.value;
|
|
252
|
+
}
|
|
253
|
+
set(name, value) {
|
|
254
|
+
const own = this.vars.get(name);
|
|
255
|
+
if (own) {
|
|
256
|
+
own.value = value;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.vars.set(name, { value });
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The cell holding `name`, taken from wherever in the chain already has one.
|
|
263
|
+
*
|
|
264
|
+
* A function reading a global must get the global's own cell, not a fresh
|
|
265
|
+
* local one that the binding would never fill. Only a name nobody has bound
|
|
266
|
+
* anywhere gets its cell here, where a later `set` will land.
|
|
267
|
+
*/
|
|
268
|
+
cell(name) {
|
|
269
|
+
const own = this.vars.get(name);
|
|
270
|
+
if (own) return own;
|
|
271
|
+
const inherited = this.parent?.cell(name);
|
|
272
|
+
if (inherited) return inherited;
|
|
273
|
+
const made = { value: void 0 };
|
|
274
|
+
this.vars.set(name, made);
|
|
275
|
+
return made;
|
|
276
|
+
}
|
|
277
|
+
child() {
|
|
278
|
+
return new MapScope(this);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Create a scope; `lookup` falls back to the parent, `set` writes locally.
|
|
283
|
+
*
|
|
284
|
+
* @param parent The enclosing scope, or nothing for a root scope.
|
|
285
|
+
*/
|
|
286
|
+
function createScope(parent) {
|
|
287
|
+
return new MapScope(parent);
|
|
288
|
+
}
|
|
289
|
+
//#endregion
|
|
290
|
+
//#region src/scheduler/collect.ts
|
|
291
|
+
/** Index the document's fragments by name for `run`. */
|
|
292
|
+
function collectFragments(doc) {
|
|
293
|
+
const map = /* @__PURE__ */ new Map();
|
|
294
|
+
for (const decl of doc.decls) if (isFragmentDecl(decl)) map.set(decl.name, decl);
|
|
295
|
+
return map;
|
|
296
|
+
}
|
|
297
|
+
/** Evaluate the top-level `config { … }` block with `env` bound, for the action context. */
|
|
298
|
+
function collectConfig(doc, env) {
|
|
299
|
+
const decl = doc.decls.find(isConfigDecl);
|
|
300
|
+
if (!decl) return {};
|
|
301
|
+
const scope = createScope();
|
|
302
|
+
scope.set("env", env);
|
|
303
|
+
return evaluate(decl.body, scope);
|
|
304
|
+
}
|
|
305
|
+
/** Gather top-level setup/teardown/beforeEach/afterEach blocks. */
|
|
306
|
+
function collectHooks(doc) {
|
|
307
|
+
const hooks = {
|
|
308
|
+
setup: [],
|
|
309
|
+
teardown: [],
|
|
310
|
+
beforeEach: [],
|
|
311
|
+
afterEach: []
|
|
312
|
+
};
|
|
313
|
+
for (const decl of doc.decls) if (isLifecycleDecl(decl)) addHook(hooks, decl);
|
|
314
|
+
return hooks;
|
|
315
|
+
}
|
|
316
|
+
function addHook(hooks, decl) {
|
|
317
|
+
const bucket = hooks[decl.hook];
|
|
318
|
+
if (bucket) bucket.push(decl);
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/scheduler/create-cleanup-list.ts
|
|
322
|
+
/**
|
|
323
|
+
* The runtime's own sink, for a host that does not bring one.
|
|
324
|
+
*
|
|
325
|
+
* Closing runs newest first and survives a cleanup that throws: a program on its
|
|
326
|
+
* way out still has to hand back everything else it was holding.
|
|
327
|
+
*/
|
|
328
|
+
function createCleanupList() {
|
|
329
|
+
const pending = [];
|
|
330
|
+
return {
|
|
331
|
+
add: (cleanup) => pending.push(cleanup),
|
|
332
|
+
close: async () => {
|
|
333
|
+
for (const cleanup of pending.splice(0).reverse()) await cleanup().catch(() => {});
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
//#endregion
|
|
338
|
+
//#region src/scheduler/filter.ts
|
|
339
|
+
/** A title matches when the needle is absent, or contained case-insensitively. */
|
|
340
|
+
function matchesTitle(title, needle) {
|
|
341
|
+
if (!needle) return true;
|
|
342
|
+
return title.toLowerCase().includes(needle.toLowerCase());
|
|
343
|
+
}
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/scheduler/invocation.ts
|
|
346
|
+
/**
|
|
347
|
+
* The dotted path an expression spells, such as `http.post`, or undefined when
|
|
348
|
+
* it is not a plain path. The parser cannot tell a namespace from a field; only
|
|
349
|
+
* the registry can, so the path is carried this far as an expression.
|
|
350
|
+
*/
|
|
351
|
+
function actionTarget(expr) {
|
|
352
|
+
if (!expr) return void 0;
|
|
353
|
+
if (isRef(expr)) return expr.name;
|
|
354
|
+
if (!isMember(expr) || expr.optional) return void 0;
|
|
355
|
+
const receiver = actionTarget(expr.receiver);
|
|
356
|
+
return receiver === void 0 ? void 0 : `${receiver}.${expr.member}`;
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The bareword action a `let` spells: `let auth = http.post url { … }`. The
|
|
360
|
+
* trailing arguments or options map are what mark it as a call. A parenthesised
|
|
361
|
+
* call (`f(x)`) is an ordinary expression the evaluator runs, since it may be a
|
|
362
|
+
* `fn` or a method rather than an action; only {@link actionCall} rescues the
|
|
363
|
+
* case where it turns out to name a plugin action.
|
|
364
|
+
*/
|
|
365
|
+
function invocationOf(stmt) {
|
|
366
|
+
return remember(invocations, stmt, readInvocation);
|
|
367
|
+
}
|
|
368
|
+
function readInvocation(stmt) {
|
|
369
|
+
if (stmt.args.length === 0 && !stmt.opts) return void 0;
|
|
370
|
+
const target = actionTarget(stmt.value);
|
|
371
|
+
if (target === void 0) return void 0;
|
|
372
|
+
return {
|
|
373
|
+
target,
|
|
374
|
+
args: stmt.args,
|
|
375
|
+
opts: stmt.opts,
|
|
376
|
+
node: stmt
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* An action written in expression position: the bare path `data.faker.uuid`, or
|
|
381
|
+
* the parenthesised `data.faker.uuid()`. Returns the target and its arguments,
|
|
382
|
+
* or undefined when it is not a dotted path at all.
|
|
383
|
+
*/
|
|
384
|
+
function actionCall(value) {
|
|
385
|
+
return remember(calls, value, readActionCall);
|
|
386
|
+
}
|
|
387
|
+
function readActionCall(value) {
|
|
388
|
+
const bare = actionTarget(value);
|
|
389
|
+
if (bare !== void 0) return {
|
|
390
|
+
target: bare,
|
|
391
|
+
args: []
|
|
392
|
+
};
|
|
393
|
+
if (!isCall(value)) return void 0;
|
|
394
|
+
const target = actionTarget(value.callee);
|
|
395
|
+
return target === void 0 ? void 0 : {
|
|
396
|
+
target,
|
|
397
|
+
args: argValues(value)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* What a node spells, worked out once.
|
|
402
|
+
*
|
|
403
|
+
* The shape of the tree decides whether `let y = x * 2` names an action, and the
|
|
404
|
+
* tree does not change, so the answer is cached per node rather than rewalked
|
|
405
|
+
* through the type guards on every execution. Whether the *name* resolves to an
|
|
406
|
+
* action depends on the scope and the registry, so that part is not cached.
|
|
407
|
+
*/
|
|
408
|
+
const invocations = /* @__PURE__ */ new WeakMap();
|
|
409
|
+
const calls = /* @__PURE__ */ new WeakMap();
|
|
410
|
+
function remember(cache, key, read) {
|
|
411
|
+
const known = cache.get(key);
|
|
412
|
+
if (known !== void 0) return known ?? void 0;
|
|
413
|
+
const found = read(key) ?? null;
|
|
414
|
+
cache.set(key, found);
|
|
415
|
+
return found ?? void 0;
|
|
416
|
+
}
|
|
417
|
+
function argValues(call) {
|
|
418
|
+
return (call.args?.args ?? []).map((arg) => arg.value);
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/scheduler/node-span.ts
|
|
422
|
+
/** The exact source text of an AST node, for the event stream. */
|
|
423
|
+
function nodeSource(node) {
|
|
424
|
+
return node.$cstNode?.text ?? "";
|
|
425
|
+
}
|
|
426
|
+
/** A Span for an AST node, for a runtime Problem. */
|
|
427
|
+
function nodeSpan(node, uri) {
|
|
428
|
+
const cst = node.$cstNode;
|
|
429
|
+
return {
|
|
430
|
+
uri,
|
|
431
|
+
offset: cst?.offset ?? 0,
|
|
432
|
+
length: cst?.length ?? 0,
|
|
433
|
+
line: (cst?.range?.start?.line ?? 0) + 1,
|
|
434
|
+
column: (cst?.range?.start?.character ?? 0) + 1
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
//#endregion
|
|
438
|
+
//#region src/scheduler/annotations.ts
|
|
439
|
+
/**
|
|
440
|
+
* Whether a decorator set this flag on the node.
|
|
441
|
+
*
|
|
442
|
+
* The scheduler reads what expansion concluded, never the annotation list
|
|
443
|
+
* itself, so a decorator is understood in exactly one place.
|
|
444
|
+
*/
|
|
445
|
+
function hasAnnotation(node, name) {
|
|
446
|
+
return readMeta(node, name) === true;
|
|
447
|
+
}
|
|
448
|
+
/** `@timeout(90s)` → milliseconds, or undefined. */
|
|
449
|
+
function readTimeout(node) {
|
|
450
|
+
return readMeta(node, "timeout");
|
|
451
|
+
}
|
|
452
|
+
/** `@retry(2, { backoff: 500ms, factor: 2 })` → a retry spec, or undefined. */
|
|
453
|
+
function readRetry(node) {
|
|
454
|
+
return readMeta(node, "retry");
|
|
455
|
+
}
|
|
456
|
+
/** `@lock("orders")` → the lock name, or undefined. */
|
|
457
|
+
function readLock(node) {
|
|
458
|
+
return readMeta(node, "lock");
|
|
459
|
+
}
|
|
460
|
+
/** `@flaky(0.05)` → the tolerated failure ratio; bare `@flaky` tolerates all. */
|
|
461
|
+
function readFlaky(node) {
|
|
462
|
+
return readMeta(node, "flaky");
|
|
463
|
+
}
|
|
464
|
+
/** `@tags(smoke, critical)` → the tag names. */
|
|
465
|
+
function readTags(node) {
|
|
466
|
+
return readMeta(node, "tags") ?? [];
|
|
467
|
+
}
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/scheduler/bind-namespaces.ts
|
|
470
|
+
/**
|
|
471
|
+
* Put every plugin namespace in the root scope as a value, so its verbs work
|
|
472
|
+
* inside any expression (`print(fmt.table(rows))`, `"${crypto.hash(x)}"`) and
|
|
473
|
+
* not only as a bare statement.
|
|
474
|
+
*
|
|
475
|
+
* A verb reached this way runs synchronously: it is the pure corner of the
|
|
476
|
+
* stdlib (`fmt`, `data`, `crypto`). An action that does I/O returns its promise,
|
|
477
|
+
* so those stay written as statements, where the runtime awaits them.
|
|
478
|
+
*/
|
|
479
|
+
function bindNamespaces(args) {
|
|
480
|
+
const byNamespace = /* @__PURE__ */ new Map();
|
|
481
|
+
for (const entry of args.registry.actions()) {
|
|
482
|
+
const members = byNamespace.get(entry.namespace) ?? {};
|
|
483
|
+
place({
|
|
484
|
+
members,
|
|
485
|
+
path: entry.name,
|
|
486
|
+
action: entry.action,
|
|
487
|
+
ctx: args.ctx
|
|
488
|
+
});
|
|
489
|
+
byNamespace.set(entry.namespace, members);
|
|
490
|
+
}
|
|
491
|
+
for (const [namespace, members] of byNamespace) args.scope.set(namespace, namespaceValue(members));
|
|
492
|
+
}
|
|
493
|
+
/** `jwt.decode` nests: `{ jwt: { decode: fn } }`, so the dotted call reads naturally. */
|
|
494
|
+
function place(args) {
|
|
495
|
+
const parts = args.path.split(".");
|
|
496
|
+
const leaf = parts.pop();
|
|
497
|
+
if (!leaf) return;
|
|
498
|
+
let level = args.members;
|
|
499
|
+
for (const part of parts) {
|
|
500
|
+
level[part] = level[part] ?? {};
|
|
501
|
+
level = level[part];
|
|
502
|
+
}
|
|
503
|
+
level[leaf] = verb(args.action, args.ctx);
|
|
504
|
+
}
|
|
505
|
+
function verb(action, ctx) {
|
|
506
|
+
return nativeFn((values) => action.run(ctx, {
|
|
507
|
+
args: values,
|
|
508
|
+
params: params(action)
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
/** No options map in expression position, so let the schema supply its defaults. */
|
|
512
|
+
function params(action) {
|
|
513
|
+
if (!action.params) return {};
|
|
514
|
+
try {
|
|
515
|
+
return action.params.parse({});
|
|
516
|
+
} catch {
|
|
517
|
+
return {};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
//#endregion
|
|
521
|
+
//#region src/scheduler/bind-prelude.ts
|
|
522
|
+
/**
|
|
523
|
+
* Put the pure prelude values (`len`, `range`, `pretty`, `str`, `min`…) in the
|
|
524
|
+
* root scope. Being values rather than verbs is what lets them appear in any
|
|
525
|
+
* expression: `xs.take(len(ys))`, `"${pretty(user)}"`.
|
|
526
|
+
*/
|
|
527
|
+
function bindPrelude(scope) {
|
|
528
|
+
for (const [name, value] of Object.entries(PRELUDE_VALUES)) scope.set(name, value);
|
|
529
|
+
}
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/scheduler/base-scope.ts
|
|
532
|
+
/**
|
|
533
|
+
* What every scope in a run starts as: the prelude, the plugin namespaces and
|
|
534
|
+
* `env`, plus the matrix variant when there is one.
|
|
535
|
+
*
|
|
536
|
+
* One place, shared by test runs and program runs, so `venn test` and `venn run`
|
|
537
|
+
* cannot drift apart. Every scope a module is read in starts here too, which is
|
|
538
|
+
* what makes an imported function see the same world its own file did.
|
|
539
|
+
*/
|
|
540
|
+
function createBaseScope(args) {
|
|
541
|
+
const scope = createScope();
|
|
542
|
+
bindPrelude(scope);
|
|
543
|
+
bindNamespaces({
|
|
544
|
+
registry: args.engine.registry,
|
|
545
|
+
ctx: args.engine.ctx,
|
|
546
|
+
scope
|
|
547
|
+
});
|
|
548
|
+
scope.set("env", args.engine.env);
|
|
549
|
+
if (args.variant) scope.set("matrix", args.variant);
|
|
550
|
+
return scope;
|
|
551
|
+
}
|
|
552
|
+
//#endregion
|
|
553
|
+
//#region src/scheduler/flaky.ts
|
|
554
|
+
/** Record one execution of a `@flaky(ratio)` node, and how much it failed by. */
|
|
555
|
+
function recordFlaky(engine, node, before) {
|
|
556
|
+
const ratio = readFlaky(node);
|
|
557
|
+
if (ratio === void 0) return;
|
|
558
|
+
const tally = engine.flaky.get(node) ?? {
|
|
559
|
+
ratio,
|
|
560
|
+
runs: 0,
|
|
561
|
+
failedRuns: 0,
|
|
562
|
+
failedUnits: 0
|
|
563
|
+
};
|
|
564
|
+
const delta = engine.result.failed - before;
|
|
565
|
+
tally.runs += 1;
|
|
566
|
+
tally.failedRuns += delta > 0 ? 1 : 0;
|
|
567
|
+
tally.failedUnits += delta > 0 ? delta : 0;
|
|
568
|
+
engine.flaky.set(node, tally);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Forgive the failures of every `@flaky` node whose observed failure ratio
|
|
572
|
+
* stayed within its declared tolerance. Settled once, so the verdict does not
|
|
573
|
+
* depend on the order iterations happened to fail in.
|
|
574
|
+
*/
|
|
575
|
+
function settleFlaky(engine) {
|
|
576
|
+
for (const tally of engine.flaky.values()) {
|
|
577
|
+
if (tally.failedRuns === 0 || tally.failedRuns / tally.runs > tally.ratio) continue;
|
|
578
|
+
engine.result.failed = Math.max(0, engine.result.failed - tally.failedUnits);
|
|
579
|
+
engine.emitter.emit({
|
|
580
|
+
kind: "log",
|
|
581
|
+
data: {
|
|
582
|
+
level: "warn",
|
|
583
|
+
message: message(tally)
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
function message(tally) {
|
|
589
|
+
return `flaky tolerated: ${tally.failedRuns}/${tally.runs} runs failed, within ratio ${tally.ratio}`;
|
|
590
|
+
}
|
|
591
|
+
//#endregion
|
|
592
|
+
//#region src/scheduler/run-around.ts
|
|
593
|
+
/**
|
|
594
|
+
* Run the `.before` and `.after` closures a `deco` left on a flow or a step.
|
|
595
|
+
*
|
|
596
|
+
* The language has no syntax for "around this body", so the verbs leave a fact
|
|
597
|
+
* behind and this is where it is honoured. `.after` runs from a `finally`,
|
|
598
|
+
* because a hook that only runs when nothing went wrong is the one case it was
|
|
599
|
+
* written for.
|
|
600
|
+
*/
|
|
601
|
+
async function runAround(node, body) {
|
|
602
|
+
const around = readDecorations(node);
|
|
603
|
+
if (!around) {
|
|
604
|
+
await body();
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
for (const fn of around.before) invoke(fn, []);
|
|
608
|
+
try {
|
|
609
|
+
await body();
|
|
610
|
+
} finally {
|
|
611
|
+
for (const fn of around.after) invoke(fn, []);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/scheduler/run-attempts.ts
|
|
616
|
+
/** Run a step/flow body applying its `@timeout` and `@retry` annotations. */
|
|
617
|
+
async function runWithAnnotations(args) {
|
|
618
|
+
const timeout = readTimeout(args.node);
|
|
619
|
+
const retry = readRetry(args.node);
|
|
620
|
+
const attempt = () => withTimeout(timeout, args.run);
|
|
621
|
+
if (!retry || retry.attempts === 0) return attempt();
|
|
622
|
+
await withRetry({
|
|
623
|
+
engine: args.engine,
|
|
624
|
+
retry,
|
|
625
|
+
title: args.title,
|
|
626
|
+
run: attempt
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
/** Hold the named mutex (`@lock`/`@serial`) around `run`, releasing on exit. */
|
|
630
|
+
async function withLock(engine, name, run) {
|
|
631
|
+
if (!name) return run();
|
|
632
|
+
const release = await engine.lock.acquire(name);
|
|
633
|
+
try {
|
|
634
|
+
await run();
|
|
635
|
+
} finally {
|
|
636
|
+
release();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
async function withTimeout(timeoutMs, run) {
|
|
640
|
+
if (timeoutMs === void 0) return run();
|
|
641
|
+
let timer;
|
|
642
|
+
const guard = new Promise((_resolve, reject) => {
|
|
643
|
+
timer = setTimeout(() => reject(timeoutError(timeoutMs)), timeoutMs);
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
await Promise.race([run(), guard]);
|
|
647
|
+
} finally {
|
|
648
|
+
if (timer) clearTimeout(timer);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async function withRetry(args) {
|
|
652
|
+
const total = args.retry.attempts + 1;
|
|
653
|
+
for (let i = 1; i <= total; i++) {
|
|
654
|
+
const snapshot = { ...args.engine.result };
|
|
655
|
+
const outcome = await attempt(args.engine, args.run);
|
|
656
|
+
if (outcome.ok) return;
|
|
657
|
+
if (i >= total) {
|
|
658
|
+
if (outcome.error) throw outcome.error;
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
Object.assign(args.engine.result, snapshot);
|
|
662
|
+
emitRetrying({
|
|
663
|
+
engine: args.engine,
|
|
664
|
+
title: args.title,
|
|
665
|
+
attempt: i
|
|
666
|
+
});
|
|
667
|
+
await args.engine.clock.sleep(backoff(args.retry, i));
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async function attempt(engine, run) {
|
|
671
|
+
const before = engine.result.failed;
|
|
672
|
+
try {
|
|
673
|
+
await run();
|
|
674
|
+
return { ok: engine.result.failed === before };
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (isControlSignal(error)) throw error;
|
|
677
|
+
return {
|
|
678
|
+
ok: false,
|
|
679
|
+
error
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
function emitRetrying(args) {
|
|
684
|
+
args.engine.emitter.emit({
|
|
685
|
+
kind: "flow.retrying",
|
|
686
|
+
data: {
|
|
687
|
+
title: args.title,
|
|
688
|
+
attempt: args.attempt,
|
|
689
|
+
reason: "previous attempt failed"
|
|
690
|
+
}
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
function backoff(retry, attempt) {
|
|
694
|
+
return retry.backoffMs * retry.factor ** (attempt - 1);
|
|
695
|
+
}
|
|
696
|
+
function timeoutError(ms) {
|
|
697
|
+
return new VennError({
|
|
698
|
+
code: "VN8001",
|
|
699
|
+
message: `Timed out after ${ms}ms.`
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/scheduler/unknown-option.ts
|
|
704
|
+
/**
|
|
705
|
+
* The keys an options map spells that the schema behind it never declared,
|
|
706
|
+
* whether that schema is an action's or a matcher's.
|
|
707
|
+
*
|
|
708
|
+
* One list, two readers: the checker squiggles them before anything runs, the
|
|
709
|
+
* runtime refuses them when the line executes. Same code, same words, so a typo
|
|
710
|
+
* cannot read one way in the editor and another in the terminal.
|
|
711
|
+
*
|
|
712
|
+
* A schema that declares no keys accepts a free-form map, and nothing there is
|
|
713
|
+
* unknown: `grpc.request` takes a `z.record`, `db.seed` takes no schema at all
|
|
714
|
+
* and reads the whole map as table names.
|
|
715
|
+
*/
|
|
716
|
+
function unknownOptions(args) {
|
|
717
|
+
const specs = paramSpecs(args.params);
|
|
718
|
+
if (!args.opts || specs.length === 0 || welcomesMore(args.params)) return [];
|
|
719
|
+
const known = new Set(specs.map((spec) => spec.name));
|
|
720
|
+
return args.opts.entries.filter((entry) => !known.has(entry.key)).map((entry) => unknownOption({
|
|
721
|
+
entry,
|
|
722
|
+
specs,
|
|
723
|
+
uri: args.uri
|
|
724
|
+
}));
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* A schema written with a catchall (`z.looseObject`, `.catchall(…)`) names some
|
|
728
|
+
* keys and takes the rest as well. A key it never named is still a key it asked
|
|
729
|
+
* to receive, so there is nothing to report. Read structurally, past the
|
|
730
|
+
* wrappers `.optional()` and `.default()` add, the way {@link paramSpecs} reads
|
|
731
|
+
* the shape.
|
|
732
|
+
*/
|
|
733
|
+
function welcomesMore(params) {
|
|
734
|
+
const def = params?.def;
|
|
735
|
+
if (def?.catchall !== void 0) return true;
|
|
736
|
+
return def?.innerType !== void 0 && welcomesMore(def.innerType);
|
|
737
|
+
}
|
|
738
|
+
function unknownOption(args) {
|
|
739
|
+
const { key } = args.entry;
|
|
740
|
+
const hint = nearest$1(key, args.specs);
|
|
741
|
+
const accepted = args.specs.map((spec) => spec.name).join(", ");
|
|
742
|
+
const title = hint ? `"${key}" is not an option here — did you mean "${hint}"?` : `"${key}" is not an option here. Accepted: ${accepted}.`;
|
|
743
|
+
return buildProblem({
|
|
744
|
+
spec: CODES.VN3001_UNKNOWN_OPTION,
|
|
745
|
+
span: nodeSpan(args.entry, args.uri),
|
|
746
|
+
title
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
/** The closest accepted key, when one is close enough to be worth suggesting. */
|
|
750
|
+
function nearest$1(key, specs) {
|
|
751
|
+
const scored = specs.map((spec) => ({
|
|
752
|
+
name: spec.name,
|
|
753
|
+
distance: distance$1(key, spec.name)
|
|
754
|
+
})).sort((left, right) => left.distance - right.distance)[0];
|
|
755
|
+
return scored && scored.distance <= Math.max(2, Math.floor(key.length / 2)) ? scored.name : void 0;
|
|
756
|
+
}
|
|
757
|
+
/** Levenshtein, small enough to keep here and precise enough for a "did you mean". */
|
|
758
|
+
function distance$1(left, right) {
|
|
759
|
+
let previous = Array.from({ length: right.length + 1 }, (_value, index) => index);
|
|
760
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
761
|
+
const current = [i];
|
|
762
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
763
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
764
|
+
current[j] = Math.min((current[j - 1] ?? 0) + 1, (previous[j] ?? 0) + 1, (previous[j - 1] ?? 0) + cost);
|
|
765
|
+
}
|
|
766
|
+
previous = current;
|
|
767
|
+
}
|
|
768
|
+
return previous[right.length] ?? 0;
|
|
769
|
+
}
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/scheduler/call-params.ts
|
|
772
|
+
/** Zod's word for a type, in the word the language uses for it. */
|
|
773
|
+
const WORDS = {
|
|
774
|
+
object: "map",
|
|
775
|
+
record: "map",
|
|
776
|
+
array: "list",
|
|
777
|
+
boolean: "bool",
|
|
778
|
+
int: "number",
|
|
779
|
+
bigint: "number"
|
|
780
|
+
};
|
|
781
|
+
/**
|
|
782
|
+
* The options map of a call, an action's or a matcher's, validated before the
|
|
783
|
+
* thing it belongs to ever sees it.
|
|
784
|
+
*
|
|
785
|
+
* Unknown keys are refused rather than stripped, because `z.object` drops them
|
|
786
|
+
* in silence and `crypto.hash "x" { algorithmm: "sha512" }` would then hash with
|
|
787
|
+
* the default. A rejected value becomes a `Problem` rather than escaping as a
|
|
788
|
+
* ZodError, whose `message` is a JSON dump of issues.
|
|
789
|
+
*
|
|
790
|
+
* @param args.site Where the call is written, for a failure with no smaller node
|
|
791
|
+
* to point at: a required option nobody typed leaves nothing to underline.
|
|
792
|
+
* @returns The parsed options.
|
|
793
|
+
* @throws ProblemError `VN3010` when a key is unknown or a value is rejected.
|
|
794
|
+
*/
|
|
795
|
+
function callParams(args) {
|
|
796
|
+
const { schema, opts, raw, site, uri } = args;
|
|
797
|
+
if (!schema) return raw;
|
|
798
|
+
const unknown = unknownOptions({
|
|
799
|
+
opts,
|
|
800
|
+
params: schema,
|
|
801
|
+
uri
|
|
802
|
+
});
|
|
803
|
+
if (unknown[0]) throw new ProblemError(unknown[0]);
|
|
804
|
+
return parsed({
|
|
805
|
+
schema,
|
|
806
|
+
opts,
|
|
807
|
+
raw,
|
|
808
|
+
site,
|
|
809
|
+
uri
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
/** Parse, and turn a rejection into a sentence about the option that failed. */
|
|
813
|
+
function parsed(args) {
|
|
814
|
+
try {
|
|
815
|
+
return args.schema.parse(args.raw);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
const issue = firstIssue(error);
|
|
818
|
+
if (!issue) throw error;
|
|
819
|
+
const { raw, opts, site, uri } = args;
|
|
820
|
+
throw new ProblemError(badOption({
|
|
821
|
+
issue,
|
|
822
|
+
raw,
|
|
823
|
+
opts,
|
|
824
|
+
site,
|
|
825
|
+
uri
|
|
826
|
+
}));
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function badOption(args) {
|
|
830
|
+
const path = args.issue.path ?? [];
|
|
831
|
+
return buildProblem({
|
|
832
|
+
spec: CODES.VN3010_TYPE_MISMATCH,
|
|
833
|
+
span: nodeSpan(entryFor(args.opts, path) ?? args.opts ?? args.site, args.uri),
|
|
834
|
+
title: title({
|
|
835
|
+
key: path.join("."),
|
|
836
|
+
issue: args.issue,
|
|
837
|
+
value: valueAt(args.raw, path)
|
|
838
|
+
})
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* One line, naming the option and what it wanted. Only the shapes worth
|
|
843
|
+
* modelling get their own sentence; anything else says so plainly rather than
|
|
844
|
+
* dressing up an issue it does not understand.
|
|
845
|
+
*/
|
|
846
|
+
function title(args) {
|
|
847
|
+
const { key, issue, value } = args;
|
|
848
|
+
if (!key) return "These options are not valid here.";
|
|
849
|
+
if (issue.code === "invalid_value" && issue.values) return oneOf(key, issue.values, value);
|
|
850
|
+
if (issue.code !== "invalid_type" || !issue.expected) return `"${key}" is not a valid option.`;
|
|
851
|
+
const needs = WORDS[issue.expected] ?? issue.expected;
|
|
852
|
+
if (value === void 0) return `"${key}" is required here, and it takes a ${needs}.`;
|
|
853
|
+
return `"${key}" needs a ${needs}, and ${shown(value)} is a ${typeName(value)}.`;
|
|
854
|
+
}
|
|
855
|
+
/** `{ algorithm: "sha5" }` against a schema that lists the digests it knows. */
|
|
856
|
+
function oneOf(key, values, value) {
|
|
857
|
+
return `"${key}" must be one of ${values.map((option) => shown(option)).join(", ")} — not ${shown(value)}.`;
|
|
858
|
+
}
|
|
859
|
+
/** A value as a message shows it: strings quoted, so `"soon"` reads as text. */
|
|
860
|
+
function shown(value) {
|
|
861
|
+
return typeof value === "string" ? JSON.stringify(value) : display(value);
|
|
862
|
+
}
|
|
863
|
+
function valueAt(raw, path) {
|
|
864
|
+
let value = raw;
|
|
865
|
+
for (const key of path) value = value?.[key];
|
|
866
|
+
return value;
|
|
867
|
+
}
|
|
868
|
+
/** The written entry to underline, when the failure names a key this map spells. */
|
|
869
|
+
function entryFor(opts, path) {
|
|
870
|
+
const key = String(path[0] ?? "");
|
|
871
|
+
return opts?.entries.find((entry) => entry.key === key);
|
|
872
|
+
}
|
|
873
|
+
/** Zod's failure, read structurally: the runtime does not depend on zod itself. */
|
|
874
|
+
function firstIssue(error) {
|
|
875
|
+
const issues = error?.issues;
|
|
876
|
+
return Array.isArray(issues) ? issues[0] : void 0;
|
|
877
|
+
}
|
|
878
|
+
//#endregion
|
|
879
|
+
//#region src/scheduler/declared-arity.ts
|
|
880
|
+
/**
|
|
881
|
+
* How many positional arguments a verb declared.
|
|
882
|
+
*
|
|
883
|
+
* Used to tell an argument that happens to be a map from the options map that
|
|
884
|
+
* configures the call. A variadic verb such as `data.oneOf a b c` takes every
|
|
885
|
+
* argument it is given, so nothing is left over to mistake for configuration.
|
|
886
|
+
*/
|
|
887
|
+
function takes(action) {
|
|
888
|
+
if (action.args?.some((each) => each.rest)) return Number.POSITIVE_INFINITY;
|
|
889
|
+
return action.args?.length ?? action.signature?.params.length ?? 0;
|
|
890
|
+
}
|
|
891
|
+
//#endregion
|
|
892
|
+
//#region src/scheduler/local-call.ts
|
|
893
|
+
/**
|
|
894
|
+
* What a dotted statement target names, when it names something in scope.
|
|
895
|
+
*
|
|
896
|
+
* `conn.close()` and `http.get "url"` are written alike and mean different
|
|
897
|
+
* things: one is a method on a value the program holds, the other a verb a
|
|
898
|
+
* plugin contributes. Only the name can tell them apart, and a name the program
|
|
899
|
+
* bound wins. That is the rule the evaluator already follows for `auth` the
|
|
900
|
+
* variable against `auth` the namespace.
|
|
901
|
+
*
|
|
902
|
+
* Returns undefined whenever this is not that: no such name, a namespace, or a
|
|
903
|
+
* path that leads somewhere uncallable. The caller then treats it as a verb,
|
|
904
|
+
* and an unknown verb reports itself as it always did.
|
|
905
|
+
*/
|
|
906
|
+
function localCallee(target, scope) {
|
|
907
|
+
const segments = target.split(".");
|
|
908
|
+
const head = segments[0];
|
|
909
|
+
if (!head) return void 0;
|
|
910
|
+
const root = scope.lookup(head);
|
|
911
|
+
if (root === void 0 || isNamespaceValue(root)) return void 0;
|
|
912
|
+
let value = root;
|
|
913
|
+
for (let at = 1; at < segments.length; at += 1) value = memberValue(value, segments[at]);
|
|
914
|
+
return isCallable(value) ? value : void 0;
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
917
|
+
//#region src/scheduler/settled.ts
|
|
918
|
+
/**
|
|
919
|
+
* Whether evaluating an expression produced something still running.
|
|
920
|
+
*
|
|
921
|
+
* Expressions compile synchronously, so a plugin verb called from inside one
|
|
922
|
+
* hands back the promise it is running on rather than its result. Statement
|
|
923
|
+
* position is already asynchronous, so it can wait, and `let id = newId()` binds
|
|
924
|
+
* the id instead of `[object Promise]`.
|
|
925
|
+
*
|
|
926
|
+
* Callers branch on this rather than awaiting unconditionally: `await` on a
|
|
927
|
+
* value that is not a promise still costs a turn of the event loop, and a
|
|
928
|
+
* `forEach` over 50k items reads one expression per iteration.
|
|
929
|
+
*/
|
|
930
|
+
function isPending(value) {
|
|
931
|
+
return value instanceof Promise;
|
|
932
|
+
}
|
|
933
|
+
/** Wait for a value only if there is something to wait for. */
|
|
934
|
+
async function settle(value) {
|
|
935
|
+
return isPending(value) ? await value : value;
|
|
936
|
+
}
|
|
937
|
+
//#endregion
|
|
938
|
+
//#region src/scheduler/target.ts
|
|
939
|
+
/**
|
|
940
|
+
* Prelude verbs, callable without `use` (§12). `print` writes to the console,
|
|
941
|
+
* `log` records into the event stream (what a reporter and a test see), and the
|
|
942
|
+
* rest steer the run. Pure prelude *values* (`len`, `range`, `pretty`…) are not
|
|
943
|
+
* here: they live in the root scope, so they work in any expression.
|
|
944
|
+
*/
|
|
945
|
+
const PRELUDE = /* @__PURE__ */ new Set([
|
|
946
|
+
"print",
|
|
947
|
+
"log",
|
|
948
|
+
"wait",
|
|
949
|
+
"skip",
|
|
950
|
+
"fail",
|
|
951
|
+
"exit"
|
|
952
|
+
]);
|
|
953
|
+
/** Split an action target `namespace.action` into parts (no dot ⇒ namespace only). */
|
|
954
|
+
function splitTarget(target) {
|
|
955
|
+
const dot = target.indexOf(".");
|
|
956
|
+
if (dot < 0) return {
|
|
957
|
+
namespace: target,
|
|
958
|
+
name: ""
|
|
959
|
+
};
|
|
960
|
+
return {
|
|
961
|
+
namespace: target.slice(0, dot),
|
|
962
|
+
name: target.slice(dot + 1)
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
/** Split a target, mapping a `use "…" as h` alias back to its real namespace. */
|
|
966
|
+
function resolveTarget(target, aliases) {
|
|
967
|
+
const { namespace, name } = splitTarget(target);
|
|
968
|
+
return {
|
|
969
|
+
namespace: aliases.get(namespace) ?? namespace,
|
|
970
|
+
name
|
|
971
|
+
};
|
|
972
|
+
}
|
|
973
|
+
//#endregion
|
|
974
|
+
//#region src/scheduler/run-action.ts
|
|
975
|
+
/**
|
|
976
|
+
* Resolve, invoke and time an action call.
|
|
977
|
+
*
|
|
978
|
+
* @returns Whatever the action produced. The caller names it: there is no
|
|
979
|
+
* implicit `res` for a reader to know about.
|
|
980
|
+
* @throws VennError `VN2003` when no plugin provides the target.
|
|
981
|
+
*/
|
|
982
|
+
async function runAction(engine, call, scope) {
|
|
983
|
+
if (PRELUDE.has(call.target)) return runPrelude(engine, call, scope);
|
|
984
|
+
const callee = localCallee(call.target, scope);
|
|
985
|
+
if (callee !== void 0) return callMethod(callee, call, scope);
|
|
986
|
+
const { namespace, name } = resolveTarget(call.target, engine.aliases);
|
|
987
|
+
const resolved = engine.registry.action({
|
|
988
|
+
namespace,
|
|
989
|
+
name
|
|
990
|
+
});
|
|
991
|
+
if (!resolved) throw unknownAction(call.target);
|
|
992
|
+
engine.emitter.emit({
|
|
993
|
+
kind: "action.started",
|
|
994
|
+
data: {
|
|
995
|
+
namespace,
|
|
996
|
+
action: name
|
|
997
|
+
}
|
|
998
|
+
});
|
|
999
|
+
const start = engine.clock.now();
|
|
1000
|
+
const value = await resolved.action.run(engine.ctx, await buildInput({
|
|
1001
|
+
action: resolved.action,
|
|
1002
|
+
call,
|
|
1003
|
+
scope,
|
|
1004
|
+
uri: engine.uri
|
|
1005
|
+
}));
|
|
1006
|
+
const durationMs = engine.clock.now() - start;
|
|
1007
|
+
engine.emitter.emit({
|
|
1008
|
+
kind: "action.finished",
|
|
1009
|
+
data: {
|
|
1010
|
+
namespace,
|
|
1011
|
+
action: name,
|
|
1012
|
+
status: "passed",
|
|
1013
|
+
durationMs
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
return value;
|
|
1017
|
+
}
|
|
1018
|
+
/** Call a value the program holds, and wait for it as any other call is waited for. */
|
|
1019
|
+
async function callMethod(callee, call, scope) {
|
|
1020
|
+
return settle(invoke(callee, await Promise.all(call.args.map((expr) => settle(evaluate(expr, scope))))));
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* An action written as a bare statement: run it, drop what it returned.
|
|
1024
|
+
*
|
|
1025
|
+
* The arguments are normalised here because the two spellings, `conn.close()`
|
|
1026
|
+
* and `http.get "url"`, put them in different places on the node.
|
|
1027
|
+
*/
|
|
1028
|
+
async function runActionStatement(engine, call, scope) {
|
|
1029
|
+
await runAction(engine, {
|
|
1030
|
+
...call,
|
|
1031
|
+
args: callArgs(call)
|
|
1032
|
+
}, scope);
|
|
1033
|
+
}
|
|
1034
|
+
async function buildInput(args) {
|
|
1035
|
+
const split = splitCall(args.call.args, takes(args.action));
|
|
1036
|
+
const opts = args.call.opts ?? split.opts;
|
|
1037
|
+
const positional = await Promise.all(split.args.map((expr) => settle(evaluate(expr, args.scope))));
|
|
1038
|
+
const raw = await settle(opts ? evaluate(opts, args.scope) : {});
|
|
1039
|
+
const schema = args.action.params;
|
|
1040
|
+
return {
|
|
1041
|
+
args: positional,
|
|
1042
|
+
params: callParams({
|
|
1043
|
+
schema,
|
|
1044
|
+
opts,
|
|
1045
|
+
raw,
|
|
1046
|
+
site: siteOf(args.call),
|
|
1047
|
+
uri: args.uri
|
|
1048
|
+
})
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Where the call is written. A call spelled as a statement is its own node; one
|
|
1053
|
+
* rescued out of a `let` carries the statement that spells it, since the
|
|
1054
|
+
* invocation itself is a plain object with no place in the source.
|
|
1055
|
+
*/
|
|
1056
|
+
function siteOf(call) {
|
|
1057
|
+
return call.node ?? call;
|
|
1058
|
+
}
|
|
1059
|
+
function unknownAction(target) {
|
|
1060
|
+
return new VennError({
|
|
1061
|
+
code: "VN2003",
|
|
1062
|
+
message: `Unknown action "${target}".`,
|
|
1063
|
+
detail: { target }
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
/** Prelude verbs, available without `use` (§12): print, log, wait, skip, fail, exit. */
|
|
1067
|
+
async function runPrelude(engine, call, scope) {
|
|
1068
|
+
const args = await Promise.all(call.args.map((arg) => settle(evaluate(arg, scope))));
|
|
1069
|
+
if (call.target === "print") return printLine(engine, args);
|
|
1070
|
+
if (call.target === "log") return logLine(engine, args);
|
|
1071
|
+
const message = String(args[0] ?? "");
|
|
1072
|
+
if (call.target === "wait") await engine.clock.sleep(waitMs(args[0]));
|
|
1073
|
+
else if (call.target === "skip") skipLog(engine, message);
|
|
1074
|
+
else if (call.target === "exit") throw new ExitSignal(exitCode(args[0]));
|
|
1075
|
+
else if (call.target === "fail") throw failError(message);
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* `print x y`: the program's own output, on standard output. Distinct from
|
|
1079
|
+
* `log`, which records into the event stream a reporter reads.
|
|
1080
|
+
*/
|
|
1081
|
+
function printLine(engine, args) {
|
|
1082
|
+
engine.ctx.port(ConsolePort).write(`${line(args)}\n`);
|
|
1083
|
+
}
|
|
1084
|
+
/** `log a b c`: every argument, spaced, objects shown as JSON not `[object Object]`. */
|
|
1085
|
+
function logLine(engine, args) {
|
|
1086
|
+
engine.emitter.emit({
|
|
1087
|
+
kind: "log",
|
|
1088
|
+
data: {
|
|
1089
|
+
level: "info",
|
|
1090
|
+
message: line(args)
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
function line(args) {
|
|
1095
|
+
return args.map(display).join(" ");
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* The code `exit` leaves with. Anything the machine cannot use as one still
|
|
1099
|
+
* ended badly, so `exit "boom"` leaves with 1 and never with 0, which would read
|
|
1100
|
+
* as success to whatever is waiting on this process.
|
|
1101
|
+
*/
|
|
1102
|
+
function exitCode(value) {
|
|
1103
|
+
const code = Math.trunc(Number(value ?? 0));
|
|
1104
|
+
return Number.isFinite(code) ? code : 1;
|
|
1105
|
+
}
|
|
1106
|
+
function skipLog(engine, message) {
|
|
1107
|
+
engine.emitter.emit({
|
|
1108
|
+
kind: "log",
|
|
1109
|
+
data: {
|
|
1110
|
+
level: "warn",
|
|
1111
|
+
message: `skipped: ${message}`
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
function waitMs(value) {
|
|
1116
|
+
if (typeof value === "number") return value;
|
|
1117
|
+
const duration = value;
|
|
1118
|
+
return duration?.kind === "duration" ? duration.ms ?? 0 : 0;
|
|
1119
|
+
}
|
|
1120
|
+
function failError(message) {
|
|
1121
|
+
return new VennError({
|
|
1122
|
+
code: "VN6002",
|
|
1123
|
+
message: message || "fail"
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
//#endregion
|
|
1127
|
+
//#region src/scheduler/run-bindings.ts
|
|
1128
|
+
/**
|
|
1129
|
+
* `let plan = "pro"` binds a value; `let auth = http.post url { … }` binds what
|
|
1130
|
+
* the action returned. Naming the result is what makes it readable: there is no
|
|
1131
|
+
* implicit variable to know about.
|
|
1132
|
+
*/
|
|
1133
|
+
function runLet(engine, stmt, scope) {
|
|
1134
|
+
const call = invocationOf(stmt) ?? impliedCall(engine, stmt, scope);
|
|
1135
|
+
if (!call) return bind$1(stmt.name, evaluate(stmt.value, scope), scope);
|
|
1136
|
+
return runAction(engine, call, scope).then((value) => scope.set(stmt.name, value));
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* `let id = data.faker.uuid` or `let id = data.faker.uuid()`: a dotted path
|
|
1140
|
+
* naming a plugin action is a call, exactly as it would be as a statement.
|
|
1141
|
+
*
|
|
1142
|
+
* A binding in scope always wins, so `let code = res.status` reads a field and
|
|
1143
|
+
* `let parts = name.split(" ")` calls a method. Neither turns into I/O, whatever
|
|
1144
|
+
* the registry happens to hold.
|
|
1145
|
+
*/
|
|
1146
|
+
function impliedCall(engine, stmt, scope) {
|
|
1147
|
+
const call = actionCall(stmt.value);
|
|
1148
|
+
const dot = call ? call.target.indexOf(".") : -1;
|
|
1149
|
+
if (!call || dot < 0 || bound(scope, call.target.slice(0, dot))) return void 0;
|
|
1150
|
+
return engine.registry.action(resolveTarget(call.target, engine.aliases)) ? {
|
|
1151
|
+
target: call.target,
|
|
1152
|
+
args: call.args,
|
|
1153
|
+
node: stmt
|
|
1154
|
+
} : void 0;
|
|
1155
|
+
}
|
|
1156
|
+
/** A name the user bound. A plugin namespace in scope is not one, so the verb still calls. */
|
|
1157
|
+
function bound(scope, name) {
|
|
1158
|
+
const value = scope.lookup(name);
|
|
1159
|
+
return value !== void 0 && !isNamespaceValue(value);
|
|
1160
|
+
}
|
|
1161
|
+
/** Bind now, or once the value it is waiting on arrives. */
|
|
1162
|
+
function bind$1(name, value, scope) {
|
|
1163
|
+
if (isPending(value)) return value.then((settled) => scope.set(name, settled));
|
|
1164
|
+
scope.set(name, value);
|
|
1165
|
+
}
|
|
1166
|
+
/** `capture` is removed in favour of `let`; still run, so old files keep working. */
|
|
1167
|
+
function runCapture(stmt, scope) {
|
|
1168
|
+
scope.set(stmt.name, evaluate(stmt.value, scope));
|
|
1169
|
+
}
|
|
1170
|
+
/** `return expr`: unwind to the enclosing fragment with the value. */
|
|
1171
|
+
function runReturn(stmt, scope) {
|
|
1172
|
+
throw new ReturnSignal(stmt.value ? evaluate(stmt.value, scope) : void 0);
|
|
1173
|
+
}
|
|
1174
|
+
//#endregion
|
|
1175
|
+
//#region src/scheduler/run-matcher.ts
|
|
1176
|
+
/** Resolve a bareword matcher from the registry and run it against the subject. */
|
|
1177
|
+
async function evalMatcher(args) {
|
|
1178
|
+
const clause = args.stmt.matcher;
|
|
1179
|
+
const resolved = args.engine.registry.matcher(clause.name);
|
|
1180
|
+
if (!resolved) throw unknownMatcher(clause.name);
|
|
1181
|
+
const input = buildArgs({
|
|
1182
|
+
...args,
|
|
1183
|
+
resolved,
|
|
1184
|
+
clause
|
|
1185
|
+
});
|
|
1186
|
+
const raw = await resolved.matcher.test(input);
|
|
1187
|
+
if (args.stmt.negate ? !raw : raw) return { passed: true };
|
|
1188
|
+
return failure({
|
|
1189
|
+
matcher: resolved.matcher,
|
|
1190
|
+
input,
|
|
1191
|
+
stmt: args.stmt
|
|
1192
|
+
});
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* A failing matcher hands back the two sides it compared, labelled with how the
|
|
1196
|
+
* flow spelled the subject. A negated expect gets no diff on purpose: under
|
|
1197
|
+
* `not` the two sides matched, and "expected 200, actual 200" explains nothing.
|
|
1198
|
+
*/
|
|
1199
|
+
function failure(args) {
|
|
1200
|
+
const message = args.matcher.message(args.input);
|
|
1201
|
+
const detail = args.stmt.negate ? void 0 : args.matcher.detail?.(args.input);
|
|
1202
|
+
if (!detail) return {
|
|
1203
|
+
passed: false,
|
|
1204
|
+
message
|
|
1205
|
+
};
|
|
1206
|
+
return {
|
|
1207
|
+
passed: false,
|
|
1208
|
+
message,
|
|
1209
|
+
diff: buildDiff({
|
|
1210
|
+
label: subjectLabel(args.stmt),
|
|
1211
|
+
...detail
|
|
1212
|
+
})
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
/** The diff's header: the subject as the flow spelled it, on one line. */
|
|
1216
|
+
function subjectLabel(stmt) {
|
|
1217
|
+
const source = stmt.subject ? nodeSource(stmt.subject).replace(/\s+/g, " ").trim() : "";
|
|
1218
|
+
return source === "" ? "value" : source;
|
|
1219
|
+
}
|
|
1220
|
+
function buildArgs(args) {
|
|
1221
|
+
const { stmt, clause, scope } = args;
|
|
1222
|
+
return {
|
|
1223
|
+
subject: stmt.subject ? evaluate(stmt.subject, scope) : void 0,
|
|
1224
|
+
args: clause.args.map((expr) => evaluate(expr, scope)),
|
|
1225
|
+
params: matcherOptions(args)
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* A matcher's options map goes through the same gate an action's does: a key it
|
|
1230
|
+
* never declared is refused rather than dropped, and a value it rejects reads as
|
|
1231
|
+
* one line. Nothing checks these before the run, since `checkOptions` only ever
|
|
1232
|
+
* sees calls, so this is the only place `{ withinn: 1 }` is ever noticed.
|
|
1233
|
+
*/
|
|
1234
|
+
function matcherOptions(args) {
|
|
1235
|
+
const { resolved, stmt, clause, scope, engine } = args;
|
|
1236
|
+
const raw = clause.opts ? evaluate(clause.opts, scope) : {};
|
|
1237
|
+
const schema = resolved.matcher.params;
|
|
1238
|
+
return callParams({
|
|
1239
|
+
schema,
|
|
1240
|
+
opts: clause.opts,
|
|
1241
|
+
raw,
|
|
1242
|
+
site: stmt,
|
|
1243
|
+
uri: engine.uri
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
function unknownMatcher(name) {
|
|
1247
|
+
return new VennError({
|
|
1248
|
+
code: "VN2004",
|
|
1249
|
+
message: `Unknown matcher "${name}".`,
|
|
1250
|
+
detail: { name }
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
//#endregion
|
|
1254
|
+
//#region src/scheduler/run-expect.ts
|
|
1255
|
+
/** Evaluate an `expect` (matcher clause, `.all` checks, negated, or subject) and emit the outcome. */
|
|
1256
|
+
async function runExpect(engine, stmt, scope) {
|
|
1257
|
+
record({
|
|
1258
|
+
engine,
|
|
1259
|
+
stmt,
|
|
1260
|
+
outcome: stmt.matcher ? await evalMatcher({
|
|
1261
|
+
engine,
|
|
1262
|
+
stmt,
|
|
1263
|
+
scope
|
|
1264
|
+
}) : { passed: await evalBoolean({
|
|
1265
|
+
stmt,
|
|
1266
|
+
scope
|
|
1267
|
+
}) }
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
async function evalBoolean(args) {
|
|
1271
|
+
return args.stmt.modifier === "all" ? evalChecks(args) : evalSubject(args);
|
|
1272
|
+
}
|
|
1273
|
+
async function evalSubject(args) {
|
|
1274
|
+
if (!args.stmt.subject) return true;
|
|
1275
|
+
const value = truthy(await settle(evaluate(args.stmt.subject, args.scope)));
|
|
1276
|
+
return args.stmt.negate ? !value : value;
|
|
1277
|
+
}
|
|
1278
|
+
async function evalChecks(args) {
|
|
1279
|
+
for (const check of args.stmt.checks) if (!truthy(await settle(evaluate(check, args.scope)))) return false;
|
|
1280
|
+
return true;
|
|
1281
|
+
}
|
|
1282
|
+
function record(args) {
|
|
1283
|
+
if (args.outcome.passed) {
|
|
1284
|
+
args.engine.result.passed += 1;
|
|
1285
|
+
args.engine.emitter.emit({
|
|
1286
|
+
kind: "expect.passed",
|
|
1287
|
+
data: { source: nodeSource(args.stmt) }
|
|
1288
|
+
});
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
args.engine.result.failed += 1;
|
|
1292
|
+
const problem = buildProblem({
|
|
1293
|
+
spec: CODES.VN6001_ASSERTION_FAILED,
|
|
1294
|
+
span: nodeSpan(args.stmt, args.engine.uri),
|
|
1295
|
+
title: args.outcome.message ?? `Expectation failed: ${nodeSource(args.stmt)}`,
|
|
1296
|
+
diff: args.outcome.diff
|
|
1297
|
+
});
|
|
1298
|
+
args.engine.emitter.emit({
|
|
1299
|
+
kind: "expect.failed",
|
|
1300
|
+
data: { problem }
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
//#endregion
|
|
1304
|
+
//#region src/scheduler/concurrency.ts
|
|
1305
|
+
/** Run `task` over `items` with at most `limit` in flight at once. */
|
|
1306
|
+
async function runPool(items, limit, task) {
|
|
1307
|
+
const size = Math.max(1, limit);
|
|
1308
|
+
let cursor = 0;
|
|
1309
|
+
const worker = async () => {
|
|
1310
|
+
while (cursor < items.length) {
|
|
1311
|
+
const index = cursor++;
|
|
1312
|
+
await task(items[index], index);
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
1316
|
+
}
|
|
1317
|
+
//#endregion
|
|
1318
|
+
//#region src/scheduler/opts.ts
|
|
1319
|
+
/** Read a numeric field from an options map literal (e.g. `{ concurrency: 4 }`). */
|
|
1320
|
+
function optsNumber(opts, key, scope) {
|
|
1321
|
+
const entry = opts?.entries.find((candidate) => candidate.key === key);
|
|
1322
|
+
if (!entry) return void 0;
|
|
1323
|
+
const value = evaluate(entry.value, scope);
|
|
1324
|
+
return typeof value === "number" ? value : void 0;
|
|
1325
|
+
}
|
|
1326
|
+
//#endregion
|
|
1327
|
+
//#region src/scheduler/run-foreach.ts
|
|
1328
|
+
/** `forEach x in list { concurrency: N } { … }`: iterate, up to N in flight. */
|
|
1329
|
+
async function runForEach(engine, stmt, scope) {
|
|
1330
|
+
const source = await settle(evaluate(stmt.source, scope));
|
|
1331
|
+
if (!Array.isArray(source)) throw notAList({
|
|
1332
|
+
engine,
|
|
1333
|
+
stmt,
|
|
1334
|
+
source
|
|
1335
|
+
});
|
|
1336
|
+
const concurrency = optsNumber(stmt.opts, "concurrency", scope) ?? 1;
|
|
1337
|
+
if (concurrency === 1) return sequential(engine, stmt, source, scope);
|
|
1338
|
+
await runPool(source, concurrency, (item) => runIteration({
|
|
1339
|
+
engine,
|
|
1340
|
+
stmt,
|
|
1341
|
+
item,
|
|
1342
|
+
scope
|
|
1343
|
+
}));
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Anything but a list is refused, because iterating it zero times would report
|
|
1347
|
+
* success: a test that checked nothing, dressed as one that passed. Built here,
|
|
1348
|
+
* off the iteration path, so the loop itself pays nothing for it.
|
|
1349
|
+
*/
|
|
1350
|
+
function notAList(args) {
|
|
1351
|
+
return new ProblemError(buildProblem({
|
|
1352
|
+
spec: CODES.VN3015_NOT_A_LIST,
|
|
1353
|
+
span: nodeSpan(args.stmt.source, args.engine.uri),
|
|
1354
|
+
title: `forEach needs a list, and this is a ${typeName(args.source)}.`,
|
|
1355
|
+
help: helpFor(args.source)
|
|
1356
|
+
}));
|
|
1357
|
+
}
|
|
1358
|
+
/** The everyday cause: an endpoint answering `{ data: [...] }` rather than a list. */
|
|
1359
|
+
function helpFor(source) {
|
|
1360
|
+
if (typeName(source) !== "map") return void 0;
|
|
1361
|
+
return "Name the list inside it, as in `forEach item in res.data`.";
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Walk the items one at a time, staying synchronous for as long as the body
|
|
1365
|
+
* does. A body of pure work (a binding, a comparison, arithmetic) never
|
|
1366
|
+
* suspends, so 50k iterations cost no promises at all. The first iteration that
|
|
1367
|
+
* does suspend hands the remainder to {@link resume}, from where it left off.
|
|
1368
|
+
*/
|
|
1369
|
+
function sequential(engine, stmt, items, scope) {
|
|
1370
|
+
const plan = planOf$1(stmt.body);
|
|
1371
|
+
const name = stmt.item;
|
|
1372
|
+
if (plan.defers) return slowSequential(engine, stmt, items, scope);
|
|
1373
|
+
const child = scope.child();
|
|
1374
|
+
for (let at = 0; at < items.length; at += 1) try {
|
|
1375
|
+
child.set(name, items[at]);
|
|
1376
|
+
const pending = runSteps(engine, plan.steps, child);
|
|
1377
|
+
if (pending) return resume$1(engine, stmt, items, at + 1, scope, pending);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
if (error instanceof BreakSignal) return void 0;
|
|
1380
|
+
if (error instanceof ContinueSignal) continue;
|
|
1381
|
+
throw error;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function slowSequential(engine, stmt, items, scope) {
|
|
1385
|
+
for (let at = 0; at < items.length; at += 1) try {
|
|
1386
|
+
const pending = iterate(engine, stmt, items[at], scope);
|
|
1387
|
+
if (pending) return resume$1(engine, stmt, items, at + 1, scope, pending);
|
|
1388
|
+
} catch (error) {
|
|
1389
|
+
if (error instanceof BreakSignal) return void 0;
|
|
1390
|
+
if (error instanceof ContinueSignal) continue;
|
|
1391
|
+
throw error;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
function iterate(engine, stmt, item, scope) {
|
|
1395
|
+
const child = scope.child();
|
|
1396
|
+
child.set(stmt.item, item);
|
|
1397
|
+
return runBlock(engine, stmt.body, child);
|
|
1398
|
+
}
|
|
1399
|
+
async function resume$1(engine, stmt, items, from, scope, pending) {
|
|
1400
|
+
try {
|
|
1401
|
+
await pending;
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
if (error instanceof BreakSignal) return;
|
|
1404
|
+
if (!(error instanceof ContinueSignal)) throw error;
|
|
1405
|
+
}
|
|
1406
|
+
for (let at = from; at < items.length; at += 1) try {
|
|
1407
|
+
await iterate(engine, stmt, items[at], scope);
|
|
1408
|
+
} catch (error) {
|
|
1409
|
+
if (error instanceof BreakSignal) return;
|
|
1410
|
+
if (error instanceof ContinueSignal) continue;
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
async function runIteration(args) {
|
|
1415
|
+
const child = args.scope.child();
|
|
1416
|
+
child.set(args.stmt.item, args.item);
|
|
1417
|
+
try {
|
|
1418
|
+
await runBlock(args.engine, args.stmt.body, child);
|
|
1419
|
+
} catch (error) {
|
|
1420
|
+
if (error instanceof BreakSignal || error instanceof ContinueSignal) return;
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
//#endregion
|
|
1425
|
+
//#region src/scheduler/run-group.ts
|
|
1426
|
+
/** A group only clusters steps in the graph; it shares the enclosing scope. */
|
|
1427
|
+
async function runGroup(engine, stmt, scope) {
|
|
1428
|
+
await runBlock(engine, stmt.body, scope);
|
|
1429
|
+
}
|
|
1430
|
+
//#endregion
|
|
1431
|
+
//#region src/scheduler/run-if.ts
|
|
1432
|
+
/** `if cond { … } else if … else { … }`: evaluate and run the taken branch. */
|
|
1433
|
+
function runIf(engine, stmt, scope) {
|
|
1434
|
+
const cond = evaluate(stmt.cond, scope);
|
|
1435
|
+
if (isPending(cond)) return cond.then((value) => void taken(engine, stmt, scope, truthy(value)));
|
|
1436
|
+
return taken(engine, stmt, scope, truthy(cond));
|
|
1437
|
+
}
|
|
1438
|
+
function taken(engine, stmt, scope, yes) {
|
|
1439
|
+
if (yes) return runBlock(engine, stmt.then, scope.child());
|
|
1440
|
+
const branch = stmt.otherwise;
|
|
1441
|
+
if (!branch) return void 0;
|
|
1442
|
+
if (isIfStmt(branch)) return runIf(engine, branch, scope);
|
|
1443
|
+
return runBlock(engine, branch, scope.child());
|
|
1444
|
+
}
|
|
1445
|
+
//#endregion
|
|
1446
|
+
//#region src/scheduler/branch-engine.ts
|
|
1447
|
+
/**
|
|
1448
|
+
* A per-branch view of the engine carrying the cancellation signal.
|
|
1449
|
+
*
|
|
1450
|
+
* Into `ctx` as well as onto the engine: the statement walker checks the signal
|
|
1451
|
+
* between statements, but a branch parked inside a long action is only reachable
|
|
1452
|
+
* through the context the action was handed. Without it, cancelling a branch
|
|
1453
|
+
* waits for whatever it is already doing to finish on its own.
|
|
1454
|
+
*/
|
|
1455
|
+
function branchEngine(engine, signal) {
|
|
1456
|
+
return {
|
|
1457
|
+
...engine,
|
|
1458
|
+
signal,
|
|
1459
|
+
ctx: {
|
|
1460
|
+
...engine.ctx,
|
|
1461
|
+
signal
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
//#endregion
|
|
1466
|
+
//#region src/scheduler/opts-text.ts
|
|
1467
|
+
/** Read a string field from an options map literal (e.g. `{ onError: "cancel" }`). */
|
|
1468
|
+
function optsText(opts, key, scope) {
|
|
1469
|
+
const entry = opts?.entries.find((candidate) => candidate.key === key);
|
|
1470
|
+
if (!entry) return void 0;
|
|
1471
|
+
const value = evaluate(entry.value, scope);
|
|
1472
|
+
return typeof value === "string" ? value : void 0;
|
|
1473
|
+
}
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/scheduler/run-parallel.ts
|
|
1476
|
+
/**
|
|
1477
|
+
* `parallel { concurrency: 2, onError: "collect" } { … }`: run each child
|
|
1478
|
+
* statement concurrently.
|
|
1479
|
+
*
|
|
1480
|
+
* The block does not outlive itself. However it ends, every branch has either
|
|
1481
|
+
* finished or been cancelled by the time this returns: `Promise.all` on its own
|
|
1482
|
+
* rejects at the first failure and leaves the rest running, which is how a
|
|
1483
|
+
* finished test carries on making requests.
|
|
1484
|
+
*/
|
|
1485
|
+
async function runParallel(engine, stmt, scope) {
|
|
1486
|
+
const limit = optsNumber(stmt.opts, "concurrency", scope) ?? stmt.body.stmts.length;
|
|
1487
|
+
const onError = optsText(stmt.opts, "onError", scope) ?? "cancel";
|
|
1488
|
+
const controller = new AbortController();
|
|
1489
|
+
const failures = [];
|
|
1490
|
+
await runPool(stmt.body.stmts, Math.max(1, limit), (child) => branch({
|
|
1491
|
+
engine,
|
|
1492
|
+
child,
|
|
1493
|
+
scope,
|
|
1494
|
+
controller,
|
|
1495
|
+
failures,
|
|
1496
|
+
onError
|
|
1497
|
+
}));
|
|
1498
|
+
report(failures);
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* One branch, and what its failure means for the others.
|
|
1502
|
+
*
|
|
1503
|
+
* `cancel`, the default, stops the siblings at their next statement boundary,
|
|
1504
|
+
* because a run that has already failed should stop working. `collect` lets them
|
|
1505
|
+
* all finish and reports every failure, which is what a set of independent
|
|
1506
|
+
* checks wants.
|
|
1507
|
+
*/
|
|
1508
|
+
async function branch(args) {
|
|
1509
|
+
const engine = branchEngine(args.engine, args.controller.signal);
|
|
1510
|
+
try {
|
|
1511
|
+
await runStatement(engine, args.child, args.scope.child());
|
|
1512
|
+
} catch (error) {
|
|
1513
|
+
if (error instanceof CancelSignal) return;
|
|
1514
|
+
if (isControlSignal(error)) throw error;
|
|
1515
|
+
args.failures.push(error);
|
|
1516
|
+
if (args.onError === "cancel") args.controller.abort();
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* One failure is raised as itself, so the reader sees the message the branch
|
|
1521
|
+
* produced. Several are raised together rather than one being picked.
|
|
1522
|
+
*/
|
|
1523
|
+
function report(failures) {
|
|
1524
|
+
if (failures.length === 0) return;
|
|
1525
|
+
if (failures.length === 1) throw failures[0];
|
|
1526
|
+
throw new AggregateError(failures, `${failures.length} parallel branches failed.`);
|
|
1527
|
+
}
|
|
1528
|
+
//#endregion
|
|
1529
|
+
//#region src/scheduler/run-race.ts
|
|
1530
|
+
/** `race { … }`: the first branch to settle wins, the losers are cancelled. */
|
|
1531
|
+
async function runRace(engine, stmt, scope) {
|
|
1532
|
+
const controller = new AbortController();
|
|
1533
|
+
const branches = stmt.body.stmts.map((child) => Promise.resolve(runStatement(branchEngine(engine, controller.signal), child, scope.child())));
|
|
1534
|
+
try {
|
|
1535
|
+
await Promise.race(branches);
|
|
1536
|
+
} finally {
|
|
1537
|
+
controller.abort();
|
|
1538
|
+
for (const branch of branches) branch.catch(() => {});
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
//#endregion
|
|
1542
|
+
//#region src/scheduler/run-repeat.ts
|
|
1543
|
+
/** `repeat N as i { … }`: run the body N times, honouring break/continue. */
|
|
1544
|
+
async function runRepeat(engine, stmt, scope) {
|
|
1545
|
+
const count = toCount(engine, stmt, evaluate(stmt.count, scope));
|
|
1546
|
+
for (let i = 1; i <= count; i++) {
|
|
1547
|
+
const child = scope.child();
|
|
1548
|
+
if (stmt.index) child.set(stmt.index, i);
|
|
1549
|
+
try {
|
|
1550
|
+
const pending = runBlock(engine, stmt.body, child);
|
|
1551
|
+
if (pending) await pending;
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if (error instanceof BreakSignal) break;
|
|
1554
|
+
if (error instanceof ContinueSignal) continue;
|
|
1555
|
+
throw error;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* A count the machine cannot read as one is refused, because running the body
|
|
1561
|
+
* zero times would report success: `repeat cfg.times` with nothing behind
|
|
1562
|
+
* `times` would check nothing and pass.
|
|
1563
|
+
*
|
|
1564
|
+
* Zero and below still mean "not at all": a bound that computes to none is a
|
|
1565
|
+
* program saying so, not a mistake.
|
|
1566
|
+
*/
|
|
1567
|
+
function toCount(engine, stmt, value) {
|
|
1568
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw notANumber(engine, stmt, value);
|
|
1569
|
+
return value > 0 ? Math.floor(value) : 0;
|
|
1570
|
+
}
|
|
1571
|
+
function notANumber(engine, stmt, value) {
|
|
1572
|
+
return new ProblemError(buildProblem({
|
|
1573
|
+
spec: CODES.VN3016_NOT_A_NUMBER,
|
|
1574
|
+
span: nodeSpan(stmt.count, engine.uri),
|
|
1575
|
+
title: `repeat needs a number of times, and this is a ${typeName(value)}.`,
|
|
1576
|
+
help: "Give it a count, as in `repeat 3 { … }`."
|
|
1577
|
+
}));
|
|
1578
|
+
}
|
|
1579
|
+
//#endregion
|
|
1580
|
+
//#region src/scheduler/run-run.ts
|
|
1581
|
+
/** `run fragment(args) as x`: invoke a fragment and bind its return value. */
|
|
1582
|
+
async function runRun(engine, stmt, scope) {
|
|
1583
|
+
const fragment = engine.fragments.get(stmt.target);
|
|
1584
|
+
if (!fragment) throw unknownFragment(stmt.target);
|
|
1585
|
+
const args = (stmt.args?.args ?? []).map((arg) => evaluate(arg.value, scope));
|
|
1586
|
+
const fragScope = createScope();
|
|
1587
|
+
bindParams(fragScope, fragment.params, args);
|
|
1588
|
+
const value = await runFragment(engine, fragment, fragScope);
|
|
1589
|
+
if (stmt.bind) scope.set(stmt.bind, value);
|
|
1590
|
+
}
|
|
1591
|
+
async function runFragment(engine, fragment, scope) {
|
|
1592
|
+
try {
|
|
1593
|
+
await runBlock(engine, fragment.body, scope);
|
|
1594
|
+
return;
|
|
1595
|
+
} catch (error) {
|
|
1596
|
+
if (error instanceof ReturnSignal) return error.value;
|
|
1597
|
+
throw error;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
function bindParams(scope, params, args) {
|
|
1601
|
+
(params?.params ?? []).forEach((param, index) => {
|
|
1602
|
+
scope.set(param.name, args[index]);
|
|
1603
|
+
});
|
|
1604
|
+
}
|
|
1605
|
+
function unknownFragment(name) {
|
|
1606
|
+
return new VennError({
|
|
1607
|
+
code: "VN2005",
|
|
1608
|
+
message: `Unknown fragment "${name}".`,
|
|
1609
|
+
detail: { fragment: name }
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
//#endregion
|
|
1613
|
+
//#region src/scheduler/run-step.ts
|
|
1614
|
+
/** Run a step honouring @skip/@only, @lock, @timeout and @retry; its bindings are step-local. */
|
|
1615
|
+
async function runStep(engine, step, parent) {
|
|
1616
|
+
if (!included(engine, step)) return;
|
|
1617
|
+
engine.emitter.emit({
|
|
1618
|
+
kind: "step.started",
|
|
1619
|
+
data: { title: step.title }
|
|
1620
|
+
});
|
|
1621
|
+
const before = engine.result.failed;
|
|
1622
|
+
const scope = parent.child();
|
|
1623
|
+
try {
|
|
1624
|
+
await execute(engine, step, {
|
|
1625
|
+
parent,
|
|
1626
|
+
scope
|
|
1627
|
+
});
|
|
1628
|
+
} catch (error) {
|
|
1629
|
+
if (!isControlSignal(error)) finished(engine, step.title, "failed");
|
|
1630
|
+
throw error;
|
|
1631
|
+
}
|
|
1632
|
+
recordFlaky(engine, step, before);
|
|
1633
|
+
finished(engine, step.title, engine.result.failed > before ? "failed" : "passed");
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* The gate `selectFlows` puts on a flow, put on a step: `@only` focuses, `@skip`
|
|
1637
|
+
* drops, and only then the `--step` title filter.
|
|
1638
|
+
*
|
|
1639
|
+
* A step is focused among the steps it stands with, meaning the block it was
|
|
1640
|
+
* written in, the way a flow is focused among the flows of its document.
|
|
1641
|
+
*/
|
|
1642
|
+
function included(engine, step) {
|
|
1643
|
+
if (hasAnnotation(step, "skip")) return false;
|
|
1644
|
+
if (focusing(step.$container) && !hasAnnotation(step, "only")) return false;
|
|
1645
|
+
return matchesTitle(step.title, engine.filter.step);
|
|
1646
|
+
}
|
|
1647
|
+
/** Whether any step in this block asked to be the only one that runs. */
|
|
1648
|
+
const focused = /* @__PURE__ */ new WeakMap();
|
|
1649
|
+
/**
|
|
1650
|
+
* Answered once per block: the source cannot change mid-run, and a step inside a
|
|
1651
|
+
* `forEach` would otherwise re-read its siblings on every pass.
|
|
1652
|
+
*/
|
|
1653
|
+
function focusing(container) {
|
|
1654
|
+
if (!isBlock(container)) return false;
|
|
1655
|
+
const known = focused.get(container);
|
|
1656
|
+
if (known !== void 0) return known;
|
|
1657
|
+
const found = container.stmts.some((stmt) => isStepDecl(stmt) && hasAnnotation(stmt, "only"));
|
|
1658
|
+
focused.set(container, found);
|
|
1659
|
+
return found;
|
|
1660
|
+
}
|
|
1661
|
+
function execute(engine, step, scopes) {
|
|
1662
|
+
return withLock(engine, readLock(step), () => runWithAnnotations({
|
|
1663
|
+
engine,
|
|
1664
|
+
node: step,
|
|
1665
|
+
scope: scopes.parent,
|
|
1666
|
+
title: step.title,
|
|
1667
|
+
run: () => runAround(step, () => runBlock(engine, step.body, scopes.scope))
|
|
1668
|
+
}));
|
|
1669
|
+
}
|
|
1670
|
+
function finished(engine, title, status) {
|
|
1671
|
+
engine.emitter.emit({
|
|
1672
|
+
kind: "step.finished",
|
|
1673
|
+
data: {
|
|
1674
|
+
title,
|
|
1675
|
+
status
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
//#endregion
|
|
1680
|
+
//#region src/scheduler/run-try.ts
|
|
1681
|
+
/** `try { } catch e { } finally { }`: control signals pass through, errors go to catch. */
|
|
1682
|
+
async function runTry(engine, stmt, scope) {
|
|
1683
|
+
try {
|
|
1684
|
+
await runBlock(engine, stmt.body, scope.child());
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
if (isControlSignal(error)) throw error;
|
|
1687
|
+
await runCatch({
|
|
1688
|
+
engine,
|
|
1689
|
+
stmt,
|
|
1690
|
+
scope,
|
|
1691
|
+
error
|
|
1692
|
+
});
|
|
1693
|
+
} finally {
|
|
1694
|
+
if (stmt.finalizer) await runBlock(engine, stmt.finalizer, scope.child());
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
async function runCatch(args) {
|
|
1698
|
+
if (!args.stmt.handler) return;
|
|
1699
|
+
const child = args.scope.child();
|
|
1700
|
+
if (args.stmt.error) child.set(args.stmt.error, toErrorValue(args.error));
|
|
1701
|
+
await runBlock(args.engine, args.stmt.handler, child);
|
|
1702
|
+
}
|
|
1703
|
+
function toErrorValue(error) {
|
|
1704
|
+
const e = error;
|
|
1705
|
+
return {
|
|
1706
|
+
message: e?.message ?? String(error),
|
|
1707
|
+
code: e?.code ?? "VN7000"
|
|
1708
|
+
};
|
|
1709
|
+
}
|
|
1710
|
+
//#endregion
|
|
1711
|
+
//#region src/scheduler/run-while.ts
|
|
1712
|
+
/**
|
|
1713
|
+
* Every `while` has an inherited timeout in the language; this is the hard cap
|
|
1714
|
+
* that stands in for it until timeouts are wired.
|
|
1715
|
+
*/
|
|
1716
|
+
const MAX_ITERATIONS = 1e5;
|
|
1717
|
+
/** `while cond { … }`: loop while the condition is truthy. */
|
|
1718
|
+
async function runWhile(engine, stmt, scope) {
|
|
1719
|
+
let guard = 0;
|
|
1720
|
+
while (truthy(await settle(evaluate(stmt.cond, scope)))) {
|
|
1721
|
+
if (++guard > MAX_ITERATIONS) throw neverFinished(engine, stmt);
|
|
1722
|
+
try {
|
|
1723
|
+
await runBlock(engine, stmt.body, scope.child());
|
|
1724
|
+
} catch (error) {
|
|
1725
|
+
if (error instanceof BreakSignal) break;
|
|
1726
|
+
if (error instanceof ContinueSignal) continue;
|
|
1727
|
+
throw error;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Reaching the cap is not the loop finishing, so it is raised rather than
|
|
1733
|
+
* returned: stopping at the limit in silence would report a pass.
|
|
1734
|
+
*/
|
|
1735
|
+
function neverFinished(engine, stmt) {
|
|
1736
|
+
return new ProblemError(buildProblem({
|
|
1737
|
+
spec: CODES.VN8002_LOOP_LIMIT,
|
|
1738
|
+
span: nodeSpan(stmt, engine.uri),
|
|
1739
|
+
title: `This while loop ran ${MAX_ITERATIONS} times and its condition was still true.`,
|
|
1740
|
+
help: "Change something the condition reads inside the loop, or use `repeat n` to bound it."
|
|
1741
|
+
}));
|
|
1742
|
+
}
|
|
1743
|
+
//#endregion
|
|
1744
|
+
//#region src/scheduler/run-statements.ts
|
|
1745
|
+
/**
|
|
1746
|
+
* Dispatch one statement to its handler. This is the single boundary where a
|
|
1747
|
+
* cancelled `race` branch stops advancing.
|
|
1748
|
+
*
|
|
1749
|
+
* Switched on `$type`, not on the `isXxx` guards: each guard asks Langium's
|
|
1750
|
+
* reflection whether one type is a subtype of another, and a chain of them costs
|
|
1751
|
+
* a double-digit share of a large `forEach`. `$type` is a plain string every
|
|
1752
|
+
* node carries, so one switch answers what thirteen questions would.
|
|
1753
|
+
*/
|
|
1754
|
+
function runStatement(engine, stmt, scope) {
|
|
1755
|
+
if (engine.signal?.aborted) throw new CancelSignal();
|
|
1756
|
+
switch (stmt.$type) {
|
|
1757
|
+
case "LetStmt": return runLet(engine, stmt, scope);
|
|
1758
|
+
case "ActionCall": return runActionStatement(engine, stmt, scope);
|
|
1759
|
+
case "ExpectStmt": return runExpect(engine, stmt, scope);
|
|
1760
|
+
case "IfStmt": return runIf(engine, stmt, scope);
|
|
1761
|
+
case "StepDecl": return runStep(engine, stmt, scope);
|
|
1762
|
+
case "GroupDecl": return runGroup(engine, stmt, scope);
|
|
1763
|
+
default: return control(engine, stmt, scope);
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/** Loops, concurrency and the control-flow signals: the rarer half. */
|
|
1767
|
+
function control(engine, stmt, scope) {
|
|
1768
|
+
switch (stmt.$type) {
|
|
1769
|
+
case "ForEachStmt": return runForEach(engine, stmt, scope);
|
|
1770
|
+
case "RepeatStmt": return runRepeat(engine, stmt, scope);
|
|
1771
|
+
case "WhileStmt": return runWhile(engine, stmt, scope);
|
|
1772
|
+
case "ParallelStmt": return runParallel(engine, stmt, scope);
|
|
1773
|
+
case "RaceStmt": return runRace(engine, stmt, scope);
|
|
1774
|
+
case "TryStmt": return runTry(engine, stmt, scope);
|
|
1775
|
+
case "RunStmt": return runRun(engine, stmt, scope);
|
|
1776
|
+
default: return leaf(stmt, scope);
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
function leaf(stmt, scope) {
|
|
1780
|
+
if (stmt.$type === "CaptureStmt") return runCapture(stmt, scope);
|
|
1781
|
+
if (stmt.$type === "ReturnStmt") return runReturn(stmt, scope);
|
|
1782
|
+
if (stmt.$type === "BreakStmt") throw new BreakSignal();
|
|
1783
|
+
if (stmt.$type === "ContinueStmt") throw new ContinueSignal();
|
|
1784
|
+
}
|
|
1785
|
+
//#endregion
|
|
1786
|
+
//#region src/scheduler/block-plan.ts
|
|
1787
|
+
const plans = /* @__PURE__ */ new WeakMap();
|
|
1788
|
+
/** The plan for a block, built on first use and cached against the node itself. */
|
|
1789
|
+
function planOf$1(block) {
|
|
1790
|
+
const known = plans.get(block);
|
|
1791
|
+
if (known) return known;
|
|
1792
|
+
const built = {
|
|
1793
|
+
steps: block.stmts.map(stepOf),
|
|
1794
|
+
defers: block.stmts.some(isDefer$2)
|
|
1795
|
+
};
|
|
1796
|
+
plans.set(block, built);
|
|
1797
|
+
return built;
|
|
1798
|
+
}
|
|
1799
|
+
function isDefer$2(stmt) {
|
|
1800
|
+
return isLifecycleDecl(stmt) && stmt.hook === "defer";
|
|
1801
|
+
}
|
|
1802
|
+
function stepOf(stmt) {
|
|
1803
|
+
return plainLet(stmt) ?? ((engine, scope) => runStatement(engine, stmt, scope));
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* `const y = x * 2`: no trailing args, no options map, and a value that is not a
|
|
1807
|
+
* dotted path, so no registry lookup can turn it into a verb. The name, the
|
|
1808
|
+
* compiled expression and the decision are all fixed by the source.
|
|
1809
|
+
*/
|
|
1810
|
+
function plainLet(stmt) {
|
|
1811
|
+
if (!isLetStmt(stmt)) return void 0;
|
|
1812
|
+
const let_ = stmt;
|
|
1813
|
+
if (let_.args.length > 0 || let_.opts) return void 0;
|
|
1814
|
+
const call = actionCall(let_.value);
|
|
1815
|
+
if (call && call.target.indexOf(".") >= 0) return void 0;
|
|
1816
|
+
const name = let_.name;
|
|
1817
|
+
const thunk = compileExpr(let_.value);
|
|
1818
|
+
return (_engine, scope) => bind(name, thunk(scope), scope);
|
|
1819
|
+
}
|
|
1820
|
+
function bind(name, value, scope) {
|
|
1821
|
+
if (isPending(value)) return value.then((settled) => scope.set(name, settled));
|
|
1822
|
+
scope.set(name, value);
|
|
1823
|
+
}
|
|
1824
|
+
//#endregion
|
|
1825
|
+
//#region src/scheduler/run-block.ts
|
|
1826
|
+
/**
|
|
1827
|
+
* Run a block's statements in order, running any `defer { … }` bodies LIFO on
|
|
1828
|
+
* the way out, including on error or a control-flow signal.
|
|
1829
|
+
*/
|
|
1830
|
+
function runBlock(engine, block, scope) {
|
|
1831
|
+
const plan = planOf$1(block);
|
|
1832
|
+
if (plan.defers) return withDefers(engine, block, scope);
|
|
1833
|
+
return runSteps(engine, plan.steps, scope);
|
|
1834
|
+
}
|
|
1835
|
+
/** The walk over a plan, reusable by a loop that hoists `planOf` out of it. */
|
|
1836
|
+
function runSteps(engine, steps, scope) {
|
|
1837
|
+
for (let at = 0; at < steps.length; at += 1) {
|
|
1838
|
+
if (engine.signal?.aborted) throw new CancelSignal();
|
|
1839
|
+
const pending = steps[at](engine, scope);
|
|
1840
|
+
if (pending) return resume(engine, steps, at + 1, scope, pending);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
async function resume(engine, steps, from, scope, pending) {
|
|
1844
|
+
await pending;
|
|
1845
|
+
for (let at = from; at < steps.length; at += 1) {
|
|
1846
|
+
if (engine.signal?.aborted) throw new CancelSignal();
|
|
1847
|
+
const next = steps[at](engine, scope);
|
|
1848
|
+
if (next) await next;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
async function withDefers(engine, block, scope) {
|
|
1852
|
+
const defers = [];
|
|
1853
|
+
try {
|
|
1854
|
+
for (const stmt of block.stmts) {
|
|
1855
|
+
if (isDefer$1(stmt)) {
|
|
1856
|
+
defers.unshift(stmt.body);
|
|
1857
|
+
continue;
|
|
1858
|
+
}
|
|
1859
|
+
const pending = runStatement(engine, stmt, scope);
|
|
1860
|
+
if (pending) await pending;
|
|
1861
|
+
}
|
|
1862
|
+
} finally {
|
|
1863
|
+
const cleanup = {
|
|
1864
|
+
...engine,
|
|
1865
|
+
signal: void 0
|
|
1866
|
+
};
|
|
1867
|
+
for (const body of defers) await runBlock(cleanup, body, scope.child());
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
function isDefer$1(stmt) {
|
|
1871
|
+
return isLifecycleDecl(stmt) && stmt.hook === "defer";
|
|
1872
|
+
}
|
|
1873
|
+
//#endregion
|
|
1874
|
+
//#region src/scheduler/run-lifecycle.ts
|
|
1875
|
+
/**
|
|
1876
|
+
* Run a set of lifecycle blocks (setup/teardown/beforeEach/afterEach) in order.
|
|
1877
|
+
*
|
|
1878
|
+
* The caller passes the scope the hooks belong to (the suite root for
|
|
1879
|
+
* setup/teardown, the flow's own scope for beforeEach/afterEach) and each body
|
|
1880
|
+
* runs in a child of it, the way the `on` handlers below do. In a parentless
|
|
1881
|
+
* scope `env`, the prelude, the top-level bindings and the open resources would
|
|
1882
|
+
* all read as undefined and the hook would run anyway: a teardown written as
|
|
1883
|
+
* `db.exec "DELETE FROM orders WHERE run = '${env.RUN_ID}'"` deletes the table
|
|
1884
|
+
* it was meant to tidy.
|
|
1885
|
+
*/
|
|
1886
|
+
async function runHooks(args) {
|
|
1887
|
+
for (const hook of args.hooks) await runHook(args.engine, hook, args.scope);
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Run the `on <event>` handlers whose event matches (e.g. "failure"/"success").
|
|
1891
|
+
*
|
|
1892
|
+
* Through the same boundary as the hooks above: a handler that reacts to a
|
|
1893
|
+
* failure by failing itself is a second failure, not the end of the run.
|
|
1894
|
+
*/
|
|
1895
|
+
async function runOnHandlers(args) {
|
|
1896
|
+
for (const handler of args.handlers) if (handler.event === args.event) await runHook(args.engine, handler, args.scope);
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* A hook that throws fails the suite instead of ending the run: it is counted
|
|
1900
|
+
* and reported, and the walk carries on to `run.finished`, so the reporters
|
|
1901
|
+
* still close their files and the files after this one still run.
|
|
1902
|
+
*/
|
|
1903
|
+
async function runHook(engine, hook, scope) {
|
|
1904
|
+
try {
|
|
1905
|
+
await runBlock(engine, hook.body, scope.child());
|
|
1906
|
+
} catch (error) {
|
|
1907
|
+
if (error instanceof ExitSignal) throw error;
|
|
1908
|
+
if (!isControlSignal(error)) recordFailure({
|
|
1909
|
+
engine,
|
|
1910
|
+
hook,
|
|
1911
|
+
error
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Counted where a failing flow is counted, and carried on the event that already
|
|
1917
|
+
* puts a Problem in front of every reporter. A hook failure the reporters never
|
|
1918
|
+
* hear about ends as a green artifact over a broken run.
|
|
1919
|
+
*/
|
|
1920
|
+
function recordFailure(args) {
|
|
1921
|
+
args.engine.result.failed += 1;
|
|
1922
|
+
args.engine.emitter.emit({
|
|
1923
|
+
kind: "expect.failed",
|
|
1924
|
+
data: { problem: hookProblem(args) }
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
function hookProblem(args) {
|
|
1928
|
+
return buildProblem({
|
|
1929
|
+
spec: CODES.VN7004_HOOK_FAILED,
|
|
1930
|
+
span: nodeSpan(args.hook, args.engine.uri),
|
|
1931
|
+
title: `${hookLabel(args.hook)} failed: ${messageOf(args.error)}`
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1934
|
+
/** The block named the way the user wrote it: `setup`, or `on failure`. */
|
|
1935
|
+
function hookLabel(hook) {
|
|
1936
|
+
return hook.hook ?? (hook.event ? `on ${hook.event}` : "lifecycle");
|
|
1937
|
+
}
|
|
1938
|
+
function messageOf(error) {
|
|
1939
|
+
return error?.message ?? String(error);
|
|
1940
|
+
}
|
|
1941
|
+
//#endregion
|
|
1942
|
+
//#region src/scheduler/run-flow.ts
|
|
1943
|
+
/** Run a flow: body, then its `on failure`/`on success` handlers, then finish. */
|
|
1944
|
+
async function runFlow(engine, flow, scope) {
|
|
1945
|
+
engine.emitter.emit({
|
|
1946
|
+
kind: "flow.started",
|
|
1947
|
+
data: { title: flow.title }
|
|
1948
|
+
});
|
|
1949
|
+
const before = engine.result.failed;
|
|
1950
|
+
const handlers = flow.body.stmts.filter(isOnHandler);
|
|
1951
|
+
await runFlowBody(engine, flow, scope);
|
|
1952
|
+
recordFlaky(engine, flow, before);
|
|
1953
|
+
const status = engine.result.failed > before ? "failed" : "passed";
|
|
1954
|
+
await runOnHandlers({
|
|
1955
|
+
engine,
|
|
1956
|
+
handlers,
|
|
1957
|
+
event: status === "failed" ? "failure" : "success",
|
|
1958
|
+
scope
|
|
1959
|
+
});
|
|
1960
|
+
engine.emitter.emit({
|
|
1961
|
+
kind: "flow.finished",
|
|
1962
|
+
data: {
|
|
1963
|
+
title: flow.title,
|
|
1964
|
+
status
|
|
1965
|
+
}
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
async function runFlowBody(engine, flow, scope) {
|
|
1969
|
+
try {
|
|
1970
|
+
await withLock(engine, flowLock(flow), () => runWithAnnotations({
|
|
1971
|
+
engine,
|
|
1972
|
+
node: flow,
|
|
1973
|
+
scope,
|
|
1974
|
+
title: flow.title,
|
|
1975
|
+
run: () => runAround(flow, () => runBlock(engine, flow.body, scope))
|
|
1976
|
+
}));
|
|
1977
|
+
} catch (error) {
|
|
1978
|
+
if (error instanceof ExitSignal) throw error;
|
|
1979
|
+
if (!isControlSignal(error)) recordError(engine, error);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
/** `@lock("x")` uses a named mutex; `@serial` serialises runs of the same flow. */
|
|
1983
|
+
function flowLock(flow) {
|
|
1984
|
+
return readLock(flow) ?? (hasAnnotation(flow, "serial") ? `serial:${flow.title}` : void 0);
|
|
1985
|
+
}
|
|
1986
|
+
function isOnHandler(stmt) {
|
|
1987
|
+
return isLifecycleDecl(stmt) && Boolean(stmt.event);
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* A failure the flow itself could not handle.
|
|
1991
|
+
*
|
|
1992
|
+
* One that already knows what it is (a Problem, with its code, its span and its
|
|
1993
|
+
* help) travels on the event every reporter reads for failures. Flattening it to
|
|
1994
|
+
* a log line would strip the code and the location the raiser worked out.
|
|
1995
|
+
*/
|
|
1996
|
+
function recordError(engine, error) {
|
|
1997
|
+
engine.result.failed += 1;
|
|
1998
|
+
if (error instanceof ProblemError) {
|
|
1999
|
+
engine.emitter.emit({
|
|
2000
|
+
kind: "expect.failed",
|
|
2001
|
+
data: { problem: error.problem }
|
|
2002
|
+
});
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
const message = error?.message ?? String(error);
|
|
2006
|
+
engine.emitter.emit({
|
|
2007
|
+
kind: "log",
|
|
2008
|
+
data: {
|
|
2009
|
+
level: "error",
|
|
2010
|
+
message
|
|
2011
|
+
}
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
//#endregion
|
|
2015
|
+
//#region src/scheduler/run-prologue.ts
|
|
2016
|
+
/**
|
|
2017
|
+
* A document's top-level statements, run once, in the order they are written.
|
|
2018
|
+
*
|
|
2019
|
+
* The same lines mean the same thing in both modes: a script *is* its prologue,
|
|
2020
|
+
* and a test file runs one before its flows. So a `const` calling a verb at the
|
|
2021
|
+
* top of a file opens the thing either way, and the flows never find `null`.
|
|
2022
|
+
*
|
|
2023
|
+
* @param args.into Where deferred cleanups accumulate, in written order. A
|
|
2024
|
+
* program's ending is registered before its statements run, so it has to see
|
|
2025
|
+
* what they defer as they reach it rather than afterwards.
|
|
2026
|
+
* @returns The teardowns collected, which is `into` when one was supplied.
|
|
2027
|
+
*/
|
|
2028
|
+
async function runPrologue(args) {
|
|
2029
|
+
const teardowns = args.into ?? [];
|
|
2030
|
+
for (const node of args.doc.decls) if (isDefer(node)) teardowns.push(deferred({
|
|
2031
|
+
...args,
|
|
2032
|
+
body: node.body
|
|
2033
|
+
}));
|
|
2034
|
+
else if (isRunnable(node)) await runStatement(args.engine, node, args.scope);
|
|
2035
|
+
return teardowns;
|
|
2036
|
+
}
|
|
2037
|
+
/** `defer { … }` written at the top of a file: registered here, run on the way out. */
|
|
2038
|
+
function isDefer(node) {
|
|
2039
|
+
return isLifecycleDecl(node) && node.hook === "defer";
|
|
2040
|
+
}
|
|
2041
|
+
function deferred(args) {
|
|
2042
|
+
const engine = {
|
|
2043
|
+
...args.engine,
|
|
2044
|
+
signal: void 0
|
|
2045
|
+
};
|
|
2046
|
+
return async () => {
|
|
2047
|
+
await runBlock(engine, args.body, args.scope.child());
|
|
2048
|
+
};
|
|
2049
|
+
}
|
|
2050
|
+
/** Undo in reverse: what opened last is given back first. */
|
|
2051
|
+
async function runTeardowns(teardowns) {
|
|
2052
|
+
for (const teardown of [...teardowns].reverse()) await teardown();
|
|
2053
|
+
}
|
|
2054
|
+
//#endregion
|
|
2055
|
+
//#region src/scheduler/run-document.ts
|
|
2056
|
+
/**
|
|
2057
|
+
* Walk a document: setup once, then for each `matrix` variant bind `env`/`matrix`
|
|
2058
|
+
* globals, open resources, run flows (with before/afterEach), close resources.
|
|
2059
|
+
*/
|
|
2060
|
+
async function runDocument(engine, doc) {
|
|
2061
|
+
const flows = selectFlows(engine, doc.decls.filter(isFlowDecl));
|
|
2062
|
+
const hooks = collectHooks(doc);
|
|
2063
|
+
engine.emitter.emit({
|
|
2064
|
+
kind: "run.started",
|
|
2065
|
+
data: { plan: planOf(flows) }
|
|
2066
|
+
});
|
|
2067
|
+
const start = engine.clock.now();
|
|
2068
|
+
await runSuite({
|
|
2069
|
+
engine,
|
|
2070
|
+
doc,
|
|
2071
|
+
flows,
|
|
2072
|
+
hooks
|
|
2073
|
+
});
|
|
2074
|
+
settleFlaky(engine);
|
|
2075
|
+
const durationMs = engine.clock.now() - start;
|
|
2076
|
+
engine.emitter.emit({
|
|
2077
|
+
kind: "run.finished",
|
|
2078
|
+
data: {
|
|
2079
|
+
...engine.result,
|
|
2080
|
+
durationMs
|
|
2081
|
+
}
|
|
2082
|
+
});
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Suite hooks run once, on either side of every variant, so they get a root of
|
|
2086
|
+
* their own: the document's bindings without any one variant's `matrix`.
|
|
2087
|
+
*
|
|
2088
|
+
* An `exit` in `setup` ends the run where it stands, so the variants never
|
|
2089
|
+
* start, and `teardown` still runs, because what `setup` opened is still open.
|
|
2090
|
+
* Both sides absorb their own, so the code reaches the host and `run.finished`
|
|
2091
|
+
* is still emitted above.
|
|
2092
|
+
*/
|
|
2093
|
+
async function runSuite(args) {
|
|
2094
|
+
const { engine, hooks } = args;
|
|
2095
|
+
const suite = rootScope({
|
|
2096
|
+
engine,
|
|
2097
|
+
doc: args.doc,
|
|
2098
|
+
variant: {}
|
|
2099
|
+
});
|
|
2100
|
+
await absorbExit(engine, async () => {
|
|
2101
|
+
await runHooks({
|
|
2102
|
+
engine,
|
|
2103
|
+
hooks: hooks.setup,
|
|
2104
|
+
scope: suite
|
|
2105
|
+
});
|
|
2106
|
+
await runVariants(args);
|
|
2107
|
+
});
|
|
2108
|
+
await absorbExit(engine, () => runHooks({
|
|
2109
|
+
engine,
|
|
2110
|
+
hooks: hooks.teardown,
|
|
2111
|
+
scope: suite
|
|
2112
|
+
}));
|
|
2113
|
+
}
|
|
2114
|
+
/** Every `matrix` variant is a full pass over the flows, one after the other. */
|
|
2115
|
+
async function runVariants(args) {
|
|
2116
|
+
for (const variant of matrixVariants(args.engine, args.doc)) await runVariant({
|
|
2117
|
+
...args,
|
|
2118
|
+
variant
|
|
2119
|
+
});
|
|
2120
|
+
}
|
|
2121
|
+
async function runVariant(args) {
|
|
2122
|
+
const root = rootScope({
|
|
2123
|
+
engine: args.engine,
|
|
2124
|
+
doc: args.doc,
|
|
2125
|
+
variant: args.variant
|
|
2126
|
+
});
|
|
2127
|
+
const teardowns = await runPrologue({
|
|
2128
|
+
engine: args.engine,
|
|
2129
|
+
doc: args.doc,
|
|
2130
|
+
scope: root
|
|
2131
|
+
});
|
|
2132
|
+
try {
|
|
2133
|
+
await runFlows({
|
|
2134
|
+
...args,
|
|
2135
|
+
root
|
|
2136
|
+
});
|
|
2137
|
+
} finally {
|
|
2138
|
+
await runTeardowns(teardowns);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
async function runFlows(args) {
|
|
2142
|
+
for (const flow of args.flows) {
|
|
2143
|
+
await runFlowWithHooks({
|
|
2144
|
+
engine: args.engine,
|
|
2145
|
+
flow,
|
|
2146
|
+
hooks: args.hooks,
|
|
2147
|
+
root: args.root
|
|
2148
|
+
});
|
|
2149
|
+
if (args.engine.bail && args.engine.result.failed > 0) break;
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
async function runFlowWithHooks(args) {
|
|
2153
|
+
const scope = args.root.child();
|
|
2154
|
+
const each = {
|
|
2155
|
+
engine: args.engine,
|
|
2156
|
+
scope
|
|
2157
|
+
};
|
|
2158
|
+
await runHooks({
|
|
2159
|
+
...each,
|
|
2160
|
+
hooks: args.hooks.beforeEach
|
|
2161
|
+
});
|
|
2162
|
+
await runFlow(args.engine, args.flow, scope);
|
|
2163
|
+
await runHooks({
|
|
2164
|
+
...each,
|
|
2165
|
+
hooks: args.hooks.afterEach
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
/**
|
|
2169
|
+
* The scope a run reads from: the prelude, the plugin namespaces, `env`, the
|
|
2170
|
+
* matrix variant and the document's own globals. One per variant, so what one
|
|
2171
|
+
* variant binds is never visible to the next.
|
|
2172
|
+
*/
|
|
2173
|
+
function rootScope(args) {
|
|
2174
|
+
const base = () => createBaseScope({
|
|
2175
|
+
engine: args.engine,
|
|
2176
|
+
variant: args.variant
|
|
2177
|
+
});
|
|
2178
|
+
const root = base();
|
|
2179
|
+
const graph = args.engine.imports;
|
|
2180
|
+
if (graph) bindImports({
|
|
2181
|
+
document: args.doc,
|
|
2182
|
+
uri: args.engine.uri,
|
|
2183
|
+
scope: root,
|
|
2184
|
+
graph,
|
|
2185
|
+
base
|
|
2186
|
+
});
|
|
2187
|
+
bindGlobals(args.doc, root);
|
|
2188
|
+
return root;
|
|
2189
|
+
}
|
|
2190
|
+
/** The cartesian product of the `matrix { … }` dimensions, else one empty variant. */
|
|
2191
|
+
function matrixVariants(engine, doc) {
|
|
2192
|
+
const matrix = doc.decls.find(isMatrixDecl);
|
|
2193
|
+
if (!matrix) return [{}];
|
|
2194
|
+
const scope = createScope();
|
|
2195
|
+
scope.set("env", engine.env);
|
|
2196
|
+
return product(evaluate(matrix.body, scope));
|
|
2197
|
+
}
|
|
2198
|
+
function product(dims) {
|
|
2199
|
+
let combos = [{}];
|
|
2200
|
+
for (const [key, values] of Object.entries(dims)) {
|
|
2201
|
+
const list = Array.isArray(values) ? values : [values];
|
|
2202
|
+
combos = combos.flatMap((combo) => list.map((value) => ({
|
|
2203
|
+
...combo,
|
|
2204
|
+
[key]: value
|
|
2205
|
+
})));
|
|
2206
|
+
}
|
|
2207
|
+
return combos;
|
|
2208
|
+
}
|
|
2209
|
+
/** `@only` focuses, `@skip` drops, then the runner's own `--flow` / `--tags` filters. */
|
|
2210
|
+
function selectFlows(engine, flows) {
|
|
2211
|
+
const only = flows.filter((flow) => hasAnnotation(flow, "only"));
|
|
2212
|
+
return byTags((only.length > 0 ? only : flows).filter((flow) => !hasAnnotation(flow, "skip")).filter((flow) => matchesTitle(flow.title, engine.filter.flow)), engine.filter.tags);
|
|
2213
|
+
}
|
|
2214
|
+
function byTags(flows, tags) {
|
|
2215
|
+
if (!tags || tags.length === 0) return flows;
|
|
2216
|
+
return flows.filter((flow) => readTags(flow).some((tag) => tags.includes(tag)));
|
|
2217
|
+
}
|
|
2218
|
+
function planOf(flows) {
|
|
2219
|
+
return { flows: flows.map((flow) => ({
|
|
2220
|
+
title: flow.title,
|
|
2221
|
+
steps: stepTitles(flow)
|
|
2222
|
+
})) };
|
|
2223
|
+
}
|
|
2224
|
+
function stepTitles(flow) {
|
|
2225
|
+
return flow.body.stmts.filter(isStepDecl).map((step) => ({ title: step.title }));
|
|
2226
|
+
}
|
|
2227
|
+
//#endregion
|
|
2228
|
+
//#region src/scheduler/script-lifecycle.ts
|
|
2229
|
+
/**
|
|
2230
|
+
* The program's `setup`, run before its first statement.
|
|
2231
|
+
*
|
|
2232
|
+
* It reads what the file declared, its functions, rather than what its
|
|
2233
|
+
* statements bind, because none of them has run yet. A `setup` that wants a
|
|
2234
|
+
* connection opens one.
|
|
2235
|
+
*/
|
|
2236
|
+
async function runSetup(args) {
|
|
2237
|
+
await runHooks({
|
|
2238
|
+
engine: args.engine,
|
|
2239
|
+
hooks: collectHooks(args.doc).setup,
|
|
2240
|
+
scope: args.scope
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* Say how the program ends, before it starts.
|
|
2245
|
+
*
|
|
2246
|
+
* Registered up front because the ending has to be in place for an interrupt
|
|
2247
|
+
* that arrives mid-statement, while what it runs is decided later, as the
|
|
2248
|
+
* statements defer things. One entry rather than one per `defer`, so the order
|
|
2249
|
+
* is stated here instead of falling out of a stack: `teardown` first, while the
|
|
2250
|
+
* connections it needs are still open, then the deferred work in reverse.
|
|
2251
|
+
*/
|
|
2252
|
+
function registerEnding(args) {
|
|
2253
|
+
const ending = { deferred: [] };
|
|
2254
|
+
const teardown = collectHooks(args.doc).teardown;
|
|
2255
|
+
args.engine.cleanup.add(async () => {
|
|
2256
|
+
await runHooks({
|
|
2257
|
+
engine: args.engine,
|
|
2258
|
+
hooks: teardown,
|
|
2259
|
+
scope: args.scope
|
|
2260
|
+
});
|
|
2261
|
+
await runTeardowns(ending.deferred);
|
|
2262
|
+
});
|
|
2263
|
+
return ending;
|
|
2264
|
+
}
|
|
2265
|
+
//#endregion
|
|
2266
|
+
//#region src/scheduler/run-script.ts
|
|
2267
|
+
/**
|
|
2268
|
+
* Run a document as a program: its top-level statements, in order, top to
|
|
2269
|
+
* bottom, because the file itself is the entry point. Declarations (`flow`,
|
|
2270
|
+
* `fn`, `type`, …) are definitions and run only when something calls them.
|
|
2271
|
+
*
|
|
2272
|
+
* `setup`, `teardown` and `defer` are the program's own lifetime: `setup` runs
|
|
2273
|
+
* before the first statement and the rest are handed to the host to run on the
|
|
2274
|
+
* way out, because a program that serves outlives its last line.
|
|
2275
|
+
*/
|
|
2276
|
+
async function runScript(engine, doc) {
|
|
2277
|
+
const base = () => createBaseScope({ engine });
|
|
2278
|
+
const root = base();
|
|
2279
|
+
const graph = engine.imports;
|
|
2280
|
+
if (graph) bindImports({
|
|
2281
|
+
document: doc,
|
|
2282
|
+
uri: engine.uri,
|
|
2283
|
+
scope: root,
|
|
2284
|
+
graph,
|
|
2285
|
+
base
|
|
2286
|
+
});
|
|
2287
|
+
bindFunctions(doc, root);
|
|
2288
|
+
engine.emitter.emit({
|
|
2289
|
+
kind: "run.started",
|
|
2290
|
+
data: { plan: { flows: [] } }
|
|
2291
|
+
});
|
|
2292
|
+
const start = engine.clock.now();
|
|
2293
|
+
await absorbExit(engine, () => runProgram(engine, doc, root));
|
|
2294
|
+
const durationMs = engine.clock.now() - start;
|
|
2295
|
+
engine.emitter.emit({
|
|
2296
|
+
kind: "run.finished",
|
|
2297
|
+
data: {
|
|
2298
|
+
...engine.result,
|
|
2299
|
+
durationMs
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* The program's lifetime: its `setup`, the ending it declared, then its
|
|
2305
|
+
* statements, all inside the one absorb, because `setup` is as much the program
|
|
2306
|
+
* as its first line. Outside it, an `exit` in `setup` would escape past
|
|
2307
|
+
* `run.finished` and leave the stream open.
|
|
2308
|
+
*
|
|
2309
|
+
* The ending is registered whatever happens to `setup`: a setup that exited or
|
|
2310
|
+
* failed still opened what it opened, and `teardown` is how it gives it back.
|
|
2311
|
+
*/
|
|
2312
|
+
async function runProgram(engine, doc, scope) {
|
|
2313
|
+
const ending = registerEnding({
|
|
2314
|
+
engine,
|
|
2315
|
+
doc,
|
|
2316
|
+
scope
|
|
2317
|
+
});
|
|
2318
|
+
await runSetup({
|
|
2319
|
+
engine,
|
|
2320
|
+
doc,
|
|
2321
|
+
scope
|
|
2322
|
+
});
|
|
2323
|
+
await runPrologue({
|
|
2324
|
+
engine,
|
|
2325
|
+
doc,
|
|
2326
|
+
scope,
|
|
2327
|
+
into: ending.deferred
|
|
2328
|
+
});
|
|
2329
|
+
}
|
|
2330
|
+
//#endregion
|
|
2331
|
+
//#region src/check/check-options.ts
|
|
2332
|
+
/**
|
|
2333
|
+
* Check the keys of an options map against the action's own schema.
|
|
2334
|
+
*
|
|
2335
|
+
* The runtime refuses an unknown key too, but only when the flow reaches that
|
|
2336
|
+
* line. Both read the same list and say the same sentence, so the editor can
|
|
2337
|
+
* mark the word before anything runs.
|
|
2338
|
+
*/
|
|
2339
|
+
function checkOptions(args) {
|
|
2340
|
+
return unknownOptions({
|
|
2341
|
+
opts: args.opts,
|
|
2342
|
+
params: args.params,
|
|
2343
|
+
uri: args.ctx.uri
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
2346
|
+
//#endregion
|
|
2347
|
+
//#region src/check/check-calls.ts
|
|
2348
|
+
/** `http.get "…"` written as a statement. */
|
|
2349
|
+
function checkAction(call, ctx) {
|
|
2350
|
+
return checkTarget({
|
|
2351
|
+
node: call,
|
|
2352
|
+
target: call.target,
|
|
2353
|
+
opts: call.opts,
|
|
2354
|
+
ctx
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* `let auth = http.post url { … }`. Only the unmistakable call is checked: with
|
|
2359
|
+
* no arguments the path could be a field of a variable this pass cannot see, and
|
|
2360
|
+
* a false "unknown action" is worse than a missed one.
|
|
2361
|
+
*/
|
|
2362
|
+
function checkLet(stmt, ctx) {
|
|
2363
|
+
if (stmt.args.length === 0 && !stmt.opts) return [];
|
|
2364
|
+
const target = actionTarget(stmt.value);
|
|
2365
|
+
if (target === void 0) return [problem$2(stmt, ctx, CODES.VN2003_UNKNOWN_ACTION, "This is not an action to call.")];
|
|
2366
|
+
return checkTarget({
|
|
2367
|
+
node: stmt,
|
|
2368
|
+
target,
|
|
2369
|
+
opts: stmt.opts,
|
|
2370
|
+
ctx
|
|
2371
|
+
});
|
|
2372
|
+
}
|
|
2373
|
+
/** `capture` is folded into `let`; say so where it is written. */
|
|
2374
|
+
function checkCapture(stmt, ctx) {
|
|
2375
|
+
return problem$2(stmt, ctx, CODES.VN5001_REMOVED_KEYWORD, "`capture` was removed — use `let` for a value that changes, `const` for one that does not.");
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* A namespace is only usable when this file brought it in with `use` (or it is
|
|
2379
|
+
* a resource). Loading the whole stdlib must not make `use` optional.
|
|
2380
|
+
*/
|
|
2381
|
+
function checkTarget(args) {
|
|
2382
|
+
const { node, target, ctx } = args;
|
|
2383
|
+
if (PRELUDE.has(target)) return [];
|
|
2384
|
+
const written = splitTarget(target).namespace;
|
|
2385
|
+
if (ctx.bound.has(written)) return [];
|
|
2386
|
+
if (!ctx.imported.has(written)) return [missingImport(args, written)];
|
|
2387
|
+
const resolved = ctx.registry.action(resolveTarget(target, ctx.aliases));
|
|
2388
|
+
if (!resolved) return [problem$2(node, ctx, CODES.VN2003_UNKNOWN_ACTION, `Unknown action "${target}".`)];
|
|
2389
|
+
return checkOptions({
|
|
2390
|
+
opts: args.opts,
|
|
2391
|
+
params: resolved.action.params,
|
|
2392
|
+
ctx
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
function missingImport(args, namespace) {
|
|
2396
|
+
if (!args.ctx.registry.hasNamespace(namespace)) {
|
|
2397
|
+
const title = `Unknown action "${args.target}" — no loaded plugin provides it.`;
|
|
2398
|
+
return problem$2(args.node, args.ctx, CODES.VN2003_UNKNOWN_ACTION, title);
|
|
2399
|
+
}
|
|
2400
|
+
const title = `"${namespace}" is not imported in this file — add a \`use\` for its package.`;
|
|
2401
|
+
return problem$2(args.node, args.ctx, CODES.VN2007_NAMESPACE_NOT_IMPORTED, title);
|
|
2402
|
+
}
|
|
2403
|
+
function problem$2(node, ctx, spec, title) {
|
|
2404
|
+
return buildProblem({
|
|
2405
|
+
spec,
|
|
2406
|
+
span: nodeSpan(node, ctx.uri),
|
|
2407
|
+
title
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2410
|
+
//#endregion
|
|
2411
|
+
//#region src/check/check-deco-body.ts
|
|
2412
|
+
/**
|
|
2413
|
+
* Check a node written inside a `deco` body, which is not the program and so is
|
|
2414
|
+
* not resolved against the registry: `target.wrap(f)` is a verb on the handle
|
|
2415
|
+
* the body was handed, and expansion settles what the rest means. The one
|
|
2416
|
+
* refusal this pass can make is a verb from a plugin, which cannot work in a
|
|
2417
|
+
* `deco` because a decorator runs before the program exists.
|
|
2418
|
+
*
|
|
2419
|
+
* @returns The node's problems, or `undefined` when the node is not inside a
|
|
2420
|
+
* `deco`, which tells the caller to apply the ordinary document checks instead.
|
|
2421
|
+
*/
|
|
2422
|
+
function checkInsideDeco(node, ctx) {
|
|
2423
|
+
const deco = enclosingDeco(node);
|
|
2424
|
+
if (!deco) return void 0;
|
|
2425
|
+
const target = calledTarget(node);
|
|
2426
|
+
const refused = target === void 0 ? void 0 : pluginVerb({
|
|
2427
|
+
deco,
|
|
2428
|
+
target,
|
|
2429
|
+
ctx
|
|
2430
|
+
});
|
|
2431
|
+
return refused ? [problem$1(node, ctx, refused)] : [];
|
|
2432
|
+
}
|
|
2433
|
+
/** The `deco` this node is written inside, if any. */
|
|
2434
|
+
function enclosingDeco(node) {
|
|
2435
|
+
for (let at = node; at; at = at.$container) if (isDecoDecl(at)) return at;
|
|
2436
|
+
}
|
|
2437
|
+
/** The dotted name this statement calls, if calling is what it does. */
|
|
2438
|
+
function calledTarget(node) {
|
|
2439
|
+
if (isActionCall(node)) return node.target;
|
|
2440
|
+
if (!isLetStmt(node) || node.args.length === 0 && !node.opts) return void 0;
|
|
2441
|
+
return actionTarget(node.value);
|
|
2442
|
+
}
|
|
2443
|
+
/**
|
|
2444
|
+
* Why this call cannot be made from here, if it cannot. Only a head the file can
|
|
2445
|
+
* already account for is refused: an imported namespace, a resource, a namespace
|
|
2446
|
+
* some loaded plugin owns. Anything else is a name only expansion resolves, and
|
|
2447
|
+
* guessing at it would put an error on every well-written decorator.
|
|
2448
|
+
*/
|
|
2449
|
+
function pluginVerb(args) {
|
|
2450
|
+
const { namespace } = splitTarget(args.target);
|
|
2451
|
+
if (PRELUDE.has(args.target) || paramNames(args.deco).has(namespace)) return void 0;
|
|
2452
|
+
if (!reachesTheWorld(namespace, args.ctx)) return void 0;
|
|
2453
|
+
return decoCannotCall(args.target);
|
|
2454
|
+
}
|
|
2455
|
+
function reachesTheWorld(namespace, ctx) {
|
|
2456
|
+
return ctx.imported.has(namespace) || ctx.bound.has(namespace) || ctx.registry.hasNamespace(namespace);
|
|
2457
|
+
}
|
|
2458
|
+
function paramNames(deco) {
|
|
2459
|
+
return new Set((deco.params?.params ?? []).map((param) => param.name));
|
|
2460
|
+
}
|
|
2461
|
+
function problem$1(node, ctx, title) {
|
|
2462
|
+
return buildProblem({
|
|
2463
|
+
spec: CODES.VN2016_DECO_IMPURE,
|
|
2464
|
+
span: nodeSpan(node, ctx.uri),
|
|
2465
|
+
title
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
//#endregion
|
|
2469
|
+
//#region src/check/check-env.ts
|
|
2470
|
+
/** `env.NAME` as it appears inside a `${…}` placeholder. */
|
|
2471
|
+
const ENV_READ = /\benv\.([A-Za-z_]\w*)/g;
|
|
2472
|
+
/** Always present: the runner sets it to the `--env` that was selected. */
|
|
2473
|
+
const BUILT_IN = /* @__PURE__ */ new Set(["name"]);
|
|
2474
|
+
/**
|
|
2475
|
+
* Match every `env.*` read against what `venn.toml` declares, so a typo is an
|
|
2476
|
+
* error rather than an empty string and a puzzling 404.
|
|
2477
|
+
*
|
|
2478
|
+
* Nothing is reported when the manifest could not be read: a wrong error about a
|
|
2479
|
+
* variable that does exist is worse than no error at all.
|
|
2480
|
+
*/
|
|
2481
|
+
function checkEnv(node, ctx) {
|
|
2482
|
+
if (!isMember(node) || isMember(node.$container)) return [];
|
|
2483
|
+
const path = actionTarget(node);
|
|
2484
|
+
const name = path?.startsWith("env.") ? path.slice(4) : void 0;
|
|
2485
|
+
if (name === void 0 || name.includes(".")) return [];
|
|
2486
|
+
const span = nodeSpan(node, ctx.uri);
|
|
2487
|
+
if (!ctx.imported.has("env")) return [notImported(span)];
|
|
2488
|
+
if (!ctx.env || declared(name, ctx.env)) return [];
|
|
2489
|
+
return [envProblem(name, span, ctx)];
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Configuration is brought in like anything else. Reading `env.*` without saying
|
|
2493
|
+
* where it comes from is the same hole `use` closes for actions and matchers:
|
|
2494
|
+
* the reader should not have to know which names are magic.
|
|
2495
|
+
*/
|
|
2496
|
+
function notImported(span) {
|
|
2497
|
+
return buildProblem({
|
|
2498
|
+
spec: CODES.VN2007_NAMESPACE_NOT_IMPORTED,
|
|
2499
|
+
span,
|
|
2500
|
+
title: "\"env\" is not imported in this file — add `use \"venn/env\"`."
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
/** The same checks for text the parser never turned into nodes: `"${env.X}"`. */
|
|
2504
|
+
function envProblemsIn(source, span, ctx) {
|
|
2505
|
+
const names = [...source.matchAll(ENV_READ)].map((match) => match[1] ?? "").filter(Boolean);
|
|
2506
|
+
if (names.length === 0) return [];
|
|
2507
|
+
if (!ctx.imported.has("env")) return [notImported(span)];
|
|
2508
|
+
const env = ctx.env;
|
|
2509
|
+
if (!env) return [];
|
|
2510
|
+
return names.filter((name) => !declared(name, env)).map((name) => envProblem(name, span, ctx));
|
|
2511
|
+
}
|
|
2512
|
+
function declared(name, env) {
|
|
2513
|
+
return env.has(name) || BUILT_IN.has(name);
|
|
2514
|
+
}
|
|
2515
|
+
/** The undeclared-variable problem, with the nearest declared name as a hint. */
|
|
2516
|
+
function envProblem(name, span, ctx) {
|
|
2517
|
+
const hint = nearest(name, [...ctx.env ?? []]);
|
|
2518
|
+
const title = hint ? `"env.${name}" is not declared in venn.toml — did you mean "env.${hint}"?` : `"env.${name}" is not declared in venn.toml.`;
|
|
2519
|
+
return buildProblem({
|
|
2520
|
+
spec: CODES.VN2006_UNKNOWN_ENV,
|
|
2521
|
+
span,
|
|
2522
|
+
title
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
2525
|
+
function nearest(name, known) {
|
|
2526
|
+
const best = known.map((candidate) => ({
|
|
2527
|
+
candidate,
|
|
2528
|
+
distance: distance(name, candidate)
|
|
2529
|
+
})).sort((left, right) => left.distance - right.distance)[0];
|
|
2530
|
+
const tolerance = Math.max(2, Math.floor(name.length / 3));
|
|
2531
|
+
return best && best.distance <= tolerance ? best.candidate : void 0;
|
|
2532
|
+
}
|
|
2533
|
+
/** Levenshtein edit distance, kept to two rows because only the score is needed. */
|
|
2534
|
+
function distance(left, right) {
|
|
2535
|
+
let previous = Array.from({ length: right.length + 1 }, (_value, index) => index);
|
|
2536
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
2537
|
+
const current = [i];
|
|
2538
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
2539
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
2540
|
+
current[j] = Math.min((current[j - 1] ?? 0) + 1, (previous[j] ?? 0) + 1, (previous[j - 1] ?? 0) + cost);
|
|
2541
|
+
}
|
|
2542
|
+
previous = current;
|
|
2543
|
+
}
|
|
2544
|
+
return previous[right.length] ?? 0;
|
|
2545
|
+
}
|
|
2546
|
+
//#endregion
|
|
2547
|
+
//#region src/check/check-fragment-call.ts
|
|
2548
|
+
/**
|
|
2549
|
+
* Refuse a fragment called as though it were a function.
|
|
2550
|
+
*
|
|
2551
|
+
* The two look alike where they are written but are different kinds of thing: a
|
|
2552
|
+
* `fn` gives back a value, a `fragment` gives back steps, and steps are recorded
|
|
2553
|
+
* in the report, can fail and belong to a flow. So a fragment is invoked with
|
|
2554
|
+
* `run`. `run entrar(…)` never reaches here: its target is a name, not an
|
|
2555
|
+
* expression.
|
|
2556
|
+
*
|
|
2557
|
+
* @param call The call expression to inspect.
|
|
2558
|
+
* @param ctx The document's resolved check context.
|
|
2559
|
+
* @returns A `VN3013` problem, or `undefined` when the callee is not a fragment.
|
|
2560
|
+
*/
|
|
2561
|
+
function checkFragmentCall(call, ctx) {
|
|
2562
|
+
const callee = call.callee;
|
|
2563
|
+
if (!isRef(callee) || !ctx.fragments.has(callee.name)) return void 0;
|
|
2564
|
+
return buildProblem({
|
|
2565
|
+
spec: CODES.VN3013_NOT_CALLABLE,
|
|
2566
|
+
span: nodeSpan(call, ctx.uri),
|
|
2567
|
+
title: `${callee.name} is a fragment, so it cannot be called for a value.`,
|
|
2568
|
+
note: `Invoke it with \`run ${callee.name}(…)\`, which records its steps in the report.`
|
|
2569
|
+
});
|
|
2570
|
+
}
|
|
2571
|
+
//#endregion
|
|
2572
|
+
//#region src/check/check-interpolation.ts
|
|
2573
|
+
/**
|
|
2574
|
+
* Check each `${…}` placeholder in a string literal and point at the placeholder
|
|
2575
|
+
* itself, not at the whole string. A slot that does not parse would otherwise
|
|
2576
|
+
* evaluate to an empty string, so a typo inside a URL fails as a puzzling 404.
|
|
2577
|
+
*/
|
|
2578
|
+
function checkInterpolation(node, ctx) {
|
|
2579
|
+
const cst = node.$cstNode;
|
|
2580
|
+
if (!isStringLit(node) || !cst) return [];
|
|
2581
|
+
return scanInterpolations(cst.text).flatMap((slot) => inSlot(slot, spanOf(slot, {
|
|
2582
|
+
cst,
|
|
2583
|
+
uri: ctx.uri
|
|
2584
|
+
}), ctx));
|
|
2585
|
+
}
|
|
2586
|
+
/** A URL is where `env` reads live, so they are checked here as well as in the AST. */
|
|
2587
|
+
function inSlot(slot, span, ctx) {
|
|
2588
|
+
if (!parseExpression(slot.source)) return [unreadable(slot, span)];
|
|
2589
|
+
return envProblemsIn(slot.source, span, ctx);
|
|
2590
|
+
}
|
|
2591
|
+
function unreadable(slot, span) {
|
|
2592
|
+
return buildProblem({
|
|
2593
|
+
spec: CODES.VN1002_PARSE,
|
|
2594
|
+
span,
|
|
2595
|
+
title: `Cannot read \`\${${slot.source}}\` — that is not an expression.`
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
/** The span of the placeholder itself: line and column follow the string's own start. */
|
|
2599
|
+
function spanOf(slot, target) {
|
|
2600
|
+
const start = target.cst.range?.start;
|
|
2601
|
+
return {
|
|
2602
|
+
uri: target.uri,
|
|
2603
|
+
offset: target.cst.offset + slot.start,
|
|
2604
|
+
length: slot.end - slot.start,
|
|
2605
|
+
line: (start?.line ?? 0) + 1,
|
|
2606
|
+
column: (start?.character ?? 0) + 1 + slot.start
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
//#endregion
|
|
2610
|
+
//#region src/check/check-uncalled.ts
|
|
2611
|
+
/**
|
|
2612
|
+
* A plugin verb named but never called.
|
|
2613
|
+
*
|
|
2614
|
+
* `let id = data.faker.uuid` runs the verb, because the runtime recognises a
|
|
2615
|
+
* bare path that names an action. The same words inside an expression evaluate
|
|
2616
|
+
* to the verb itself, so a program meaning to read a value gets a function.
|
|
2617
|
+
* Rather than guess which was meant, this asks for the parentheses.
|
|
2618
|
+
*/
|
|
2619
|
+
function checkUncalledAction(node, ctx) {
|
|
2620
|
+
if (!isMember(node) || !readsAsValue(node)) return void 0;
|
|
2621
|
+
const target = actionTarget(node);
|
|
2622
|
+
if (target === void 0) return void 0;
|
|
2623
|
+
if (!ctx.registry.action(resolveTarget(target, ctx.aliases))) return void 0;
|
|
2624
|
+
return buildProblem({
|
|
2625
|
+
spec: CODES.VN2008_UNCALLED_ACTION,
|
|
2626
|
+
span: nodeSpan(node, ctx.uri),
|
|
2627
|
+
title: `\`${target}\` is a verb, not a value — write \`${target}()\` to call it.`
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
2631
|
+
* Where naming the verb produces the verb. Skipped when it is the head of a
|
|
2632
|
+
* longer path or the thing being called, and when it stands as a statement's
|
|
2633
|
+
* value, which the runtime calls anyway.
|
|
2634
|
+
*/
|
|
2635
|
+
function readsAsValue(node) {
|
|
2636
|
+
const parent = node.$container;
|
|
2637
|
+
return !isMember(parent) && !isCall(parent) && !isLetStmt(parent);
|
|
2638
|
+
}
|
|
2639
|
+
//#endregion
|
|
2640
|
+
//#region src/check/check-document.ts
|
|
2641
|
+
/**
|
|
2642
|
+
* Statically resolve every action, matcher and fragment reference in a parsed
|
|
2643
|
+
* document. The errors the runner would otherwise raise mid-run are surfaced all
|
|
2644
|
+
* at once, each with its source span.
|
|
2645
|
+
*
|
|
2646
|
+
* @param args Document, registry, known fragments and the declared `env` names.
|
|
2647
|
+
* @returns One `Problem` per unresolved reference; empty when the document is clean.
|
|
2648
|
+
*/
|
|
2649
|
+
function checkDocument(args) {
|
|
2650
|
+
const ctx = {
|
|
2651
|
+
registry: args.registry,
|
|
2652
|
+
fragments: args.fragments,
|
|
2653
|
+
aliases: collectAliases(args.document, args.registry),
|
|
2654
|
+
imported: collectNamespaces(args.document, args.registry),
|
|
2655
|
+
bound: collectBoundNames(args.document),
|
|
2656
|
+
env: args.env ? new Set(args.env) : void 0,
|
|
2657
|
+
uri: args.uri ?? "memory://inline.vn"
|
|
2658
|
+
};
|
|
2659
|
+
const problems = [];
|
|
2660
|
+
for (const node of walkAst(args.document)) {
|
|
2661
|
+
const inDeco = checkInsideDeco(node, ctx);
|
|
2662
|
+
if (inDeco) problems.push(...inDeco);
|
|
2663
|
+
else problems.push(...everyCheck(node, ctx));
|
|
2664
|
+
}
|
|
2665
|
+
return problems;
|
|
2666
|
+
}
|
|
2667
|
+
function everyCheck(node, ctx) {
|
|
2668
|
+
return [
|
|
2669
|
+
...checkNode(node, ctx),
|
|
2670
|
+
...checkEnv(node, ctx),
|
|
2671
|
+
...checkInterpolation(node, ctx),
|
|
2672
|
+
...one(checkUncalledAction(node, ctx))
|
|
2673
|
+
];
|
|
2674
|
+
}
|
|
2675
|
+
function checkNode(node, ctx) {
|
|
2676
|
+
if (isActionCall(node)) return checkAction(node, ctx);
|
|
2677
|
+
if (isLetStmt(node)) return checkLet(node, ctx);
|
|
2678
|
+
if (isCaptureStmt(node)) return [checkCapture(node, ctx)];
|
|
2679
|
+
if (isMatcherClause(node)) return one(checkMatcher(node, ctx));
|
|
2680
|
+
if (isRunStmt(node)) return one(checkFragment(node, ctx));
|
|
2681
|
+
if (isCall(node)) return one(checkFragmentCall(node, ctx));
|
|
2682
|
+
return [];
|
|
2683
|
+
}
|
|
2684
|
+
function one(problem) {
|
|
2685
|
+
return problem ? [problem] : [];
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* A matcher comes from a plugin like any action, so the file has to bring that
|
|
2689
|
+
* plugin in. Resolving against the whole loaded stdlib would make `use` decorative.
|
|
2690
|
+
*/
|
|
2691
|
+
function checkMatcher(clause, ctx) {
|
|
2692
|
+
const owner = ctx.registry.matcher(clause.name);
|
|
2693
|
+
if (!owner) return problem(clause, ctx, CODES.VN2004_UNKNOWN_MATCHER, `Unknown matcher "${clause.name}".`);
|
|
2694
|
+
if (ctx.imported.has(owner.plugin.namespace)) return void 0;
|
|
2695
|
+
const title = `"${clause.name}" comes from "${owner.plugin.namespace}", which is not imported in this file.`;
|
|
2696
|
+
return problem(clause, ctx, CODES.VN2007_NAMESPACE_NOT_IMPORTED, title);
|
|
2697
|
+
}
|
|
2698
|
+
function checkFragment(stmt, ctx) {
|
|
2699
|
+
if (ctx.fragments.has(stmt.target)) return void 0;
|
|
2700
|
+
return problem(stmt, ctx, CODES.VN2005_UNKNOWN_FRAGMENT, `Unknown fragment "${stmt.target}".`);
|
|
2701
|
+
}
|
|
2702
|
+
function problem(node, ctx, spec, title) {
|
|
2703
|
+
return buildProblem({
|
|
2704
|
+
spec,
|
|
2705
|
+
span: nodeSpan(node, ctx.uri),
|
|
2706
|
+
title
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
//#endregion
|
|
2710
|
+
//#region src/check/check-imports.ts
|
|
2711
|
+
/**
|
|
2712
|
+
* Check every name a file imports against what the file it names publishes.
|
|
2713
|
+
* Otherwise a misspelt import stays quietly `undefined` until something calls
|
|
2714
|
+
* it, and the run blames the call site rather than the import.
|
|
2715
|
+
*
|
|
2716
|
+
* @param args The importing document, its URI, and the resolved import graph.
|
|
2717
|
+
* @returns One `VN2009` problem per name the target does not publish.
|
|
2718
|
+
*/
|
|
2719
|
+
function checkImports(args) {
|
|
2720
|
+
const problems = [];
|
|
2721
|
+
for (const decl of args.document.imports) {
|
|
2722
|
+
if (!isValueImport(decl)) continue;
|
|
2723
|
+
const target = args.graph.resolve(args.uri, decl.path);
|
|
2724
|
+
const module = args.graph.modules.get(target);
|
|
2725
|
+
if (module) problems.push(...missing({
|
|
2726
|
+
decl,
|
|
2727
|
+
module,
|
|
2728
|
+
uri: args.uri,
|
|
2729
|
+
path: decl.path
|
|
2730
|
+
}));
|
|
2731
|
+
}
|
|
2732
|
+
return problems;
|
|
2733
|
+
}
|
|
2734
|
+
function missing(args) {
|
|
2735
|
+
const published = exported(args.module);
|
|
2736
|
+
return args.decl.names.filter((name) => !published.has(name)).map((name) => buildProblem({
|
|
2737
|
+
spec: CODES.VN2009_NOT_EXPORTED,
|
|
2738
|
+
span: nodeSpan(args.decl, args.uri),
|
|
2739
|
+
title: `"${args.path}" does not publish ${name}.`,
|
|
2740
|
+
note: hint(name, args.module)
|
|
2741
|
+
}));
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Why it is not there: written but kept private, or not written at all. The two
|
|
2745
|
+
* are different mistakes with different fixes, and a reader told only "not
|
|
2746
|
+
* published" goes looking for a typo they did not make.
|
|
2747
|
+
*/
|
|
2748
|
+
function hint(name, module) {
|
|
2749
|
+
return module.decls.some((decl) => (isFnDecl(decl) || isFragmentDecl(decl) || isDecoDecl(decl)) && decl.name === name) ? `It is declared there, but not marked \`pub\`.` : `Nothing of that name is declared there.`;
|
|
2750
|
+
}
|
|
2751
|
+
/** Everything a file marked `pub`: functions, fragments and decorators alike. */
|
|
2752
|
+
function exported(document) {
|
|
2753
|
+
const names = /* @__PURE__ */ new Set();
|
|
2754
|
+
for (const decl of document.decls) {
|
|
2755
|
+
if (!isFnDecl(decl) && !isFragmentDecl(decl) && !isDecoDecl(decl)) continue;
|
|
2756
|
+
if (decl.export) names.add(decl.name);
|
|
2757
|
+
}
|
|
2758
|
+
return names;
|
|
2759
|
+
}
|
|
2760
|
+
//#endregion
|
|
2761
|
+
//#region src/decorators/builtin-decorators.ts
|
|
2762
|
+
/** Where each of these may sit. Named once, so a wrong target is caught, not ignored. */
|
|
2763
|
+
const RUNNABLE = [
|
|
2764
|
+
"FlowDecl",
|
|
2765
|
+
"StepDecl",
|
|
2766
|
+
"GroupDecl"
|
|
2767
|
+
];
|
|
2768
|
+
/**
|
|
2769
|
+
* The decorators the language ships with, written the same way anyone else's
|
|
2770
|
+
* are. They go through the same expansion as a plugin decorator and leave
|
|
2771
|
+
* metadata on the node, because `@retry(2)` says something the grammar has no
|
|
2772
|
+
* other word for. Keeping them here rather than as special cases in the
|
|
2773
|
+
* scheduler is what makes the built-ins a stdlib instead of reserved words.
|
|
2774
|
+
*/
|
|
2775
|
+
const builtinDecorators = [
|
|
2776
|
+
flag("skip", "Do not run this."),
|
|
2777
|
+
flag("only", "Run this and nothing beside it."),
|
|
2778
|
+
flag("serial", "Never run this alongside another flow."),
|
|
2779
|
+
{
|
|
2780
|
+
name: "tags",
|
|
2781
|
+
doc: "Label this for `--tags`.",
|
|
2782
|
+
targets: [...RUNNABLE],
|
|
2783
|
+
expand: (ctx) => ctx.meta("tags", ctx.args.map(String).filter((tag) => tag !== ""))
|
|
2784
|
+
},
|
|
2785
|
+
{
|
|
2786
|
+
name: "timeout",
|
|
2787
|
+
doc: "Give up after this long.",
|
|
2788
|
+
targets: [...RUNNABLE],
|
|
2789
|
+
expand: (ctx) => ctx.meta("timeout", durationMs(ctx.args[0]))
|
|
2790
|
+
},
|
|
2791
|
+
{
|
|
2792
|
+
name: "retry",
|
|
2793
|
+
doc: "Run again on failure, up to n times.",
|
|
2794
|
+
targets: [...RUNNABLE],
|
|
2795
|
+
expand: (ctx) => ctx.meta("retry", retryOf(ctx))
|
|
2796
|
+
},
|
|
2797
|
+
{
|
|
2798
|
+
name: "lock",
|
|
2799
|
+
doc: "Hold this name for the duration; nothing else holding it runs.",
|
|
2800
|
+
targets: [...RUNNABLE],
|
|
2801
|
+
expand: (ctx) => ctx.meta("lock", ctx.args[0] === void 0 ? void 0 : String(ctx.args[0]))
|
|
2802
|
+
},
|
|
2803
|
+
{
|
|
2804
|
+
name: "flaky",
|
|
2805
|
+
doc: "Tolerate failures up to this ratio.",
|
|
2806
|
+
targets: [...RUNNABLE],
|
|
2807
|
+
expand: (ctx) => ctx.meta("flaky", ratioOf(ctx.args[0]))
|
|
2808
|
+
}
|
|
2809
|
+
];
|
|
2810
|
+
/** A decorator with nothing to say beyond having been written. */
|
|
2811
|
+
function flag(name, doc) {
|
|
2812
|
+
return {
|
|
2813
|
+
name,
|
|
2814
|
+
doc,
|
|
2815
|
+
targets: [...RUNNABLE],
|
|
2816
|
+
expand: (ctx) => ctx.meta(name, true)
|
|
2817
|
+
};
|
|
2818
|
+
}
|
|
2819
|
+
function retryOf(ctx) {
|
|
2820
|
+
const opts = asRecord(ctx.args[1]);
|
|
2821
|
+
return {
|
|
2822
|
+
attempts: Math.max(0, Math.floor(Number(ctx.args[0]) || 0)),
|
|
2823
|
+
backoffMs: durationMs(opts.backoff) ?? 0,
|
|
2824
|
+
factor: Number(opts.factor ?? 1) || 1
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
/** A bare `@flaky` tolerates everything; a number is the ratio it tolerates. */
|
|
2828
|
+
function ratioOf(value) {
|
|
2829
|
+
if (value === void 0) return 1;
|
|
2830
|
+
return typeof value === "number" ? value : 0;
|
|
2831
|
+
}
|
|
2832
|
+
function durationMs(value) {
|
|
2833
|
+
if (typeof value === "number") return value;
|
|
2834
|
+
const duration = value;
|
|
2835
|
+
return duration?.kind === "duration" ? duration.ms : void 0;
|
|
2836
|
+
}
|
|
2837
|
+
function asRecord(value) {
|
|
2838
|
+
return typeof value === "object" && value !== null ? value : {};
|
|
2839
|
+
}
|
|
2840
|
+
//#endregion
|
|
2841
|
+
//#region src/decorators/create-decorator-source.ts
|
|
2842
|
+
/**
|
|
2843
|
+
* Every decorator this run understands: the ones the language ships with, plus
|
|
2844
|
+
* whatever the loaded plugins contribute.
|
|
2845
|
+
*
|
|
2846
|
+
* A plugin's decorator overrides a built-in of the same name on purpose. The
|
|
2847
|
+
* built-ins are a stdlib, not a reserved word list, and a project that wants its
|
|
2848
|
+
* own `@retry` is entitled to it.
|
|
2849
|
+
*
|
|
2850
|
+
* @param plugins The plugins loaded for this run, in load order.
|
|
2851
|
+
* @returns A source that looks a decorator up by name.
|
|
2852
|
+
*/
|
|
2853
|
+
function createDecoratorSource(plugins) {
|
|
2854
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2855
|
+
for (const decorator of builtinDecorators) byName.set(decorator.name, decorator);
|
|
2856
|
+
for (const plugin of plugins) for (const decorator of plugin.decorators ?? []) byName.set(decorator.name, decorator);
|
|
2857
|
+
return { get: (name) => byName.get(name) };
|
|
2858
|
+
}
|
|
2859
|
+
//#endregion
|
|
2860
|
+
//#region src/emit/create-emitter.ts
|
|
2861
|
+
/** Build the sole emitter: it owns `seq` and stamps `ts` from the runner's clock. */
|
|
2862
|
+
function createEmitter(args) {
|
|
2863
|
+
let seq = 0;
|
|
2864
|
+
return { emit: ({ kind, data, node }) => {
|
|
2865
|
+
seq += 1;
|
|
2866
|
+
const ts = new Date(args.clock.now()).toISOString();
|
|
2867
|
+
args.sink.emit({
|
|
2868
|
+
seq,
|
|
2869
|
+
ts,
|
|
2870
|
+
run: args.run,
|
|
2871
|
+
kind,
|
|
2872
|
+
node,
|
|
2873
|
+
data
|
|
2874
|
+
});
|
|
2875
|
+
} };
|
|
2876
|
+
}
|
|
2877
|
+
//#endregion
|
|
2878
|
+
//#region src/emit/run-id.ts
|
|
2879
|
+
/**
|
|
2880
|
+
* Mint a run id from the host clock and random source, so a seeded run is
|
|
2881
|
+
* reproducible. Real ULID minting comes later.
|
|
2882
|
+
*/
|
|
2883
|
+
function newRunId(args) {
|
|
2884
|
+
const rand = Math.floor(args.random.next() * 1e9).toString(36);
|
|
2885
|
+
return `run-${args.clock.now().toString(36)}-${rand}`;
|
|
2886
|
+
}
|
|
2887
|
+
//#endregion
|
|
2888
|
+
//#region src/eventsink/event-sink.port.ts
|
|
2889
|
+
/**
|
|
2890
|
+
* The port every run event is written to. Bound at startup by whoever assembles
|
|
2891
|
+
* the host: the CLI binds NDJSON on stdout, tests bind the memory double.
|
|
2892
|
+
*/
|
|
2893
|
+
const EventSinkPort = {
|
|
2894
|
+
id: "venn.port.event-sink",
|
|
2895
|
+
version: 1,
|
|
2896
|
+
requires: [],
|
|
2897
|
+
methods: ["emit"]
|
|
2898
|
+
};
|
|
2899
|
+
//#endregion
|
|
2900
|
+
//#region src/eventsink/memory-sink.ts
|
|
2901
|
+
/**
|
|
2902
|
+
* In-memory sink (the test double): keeps every envelope for assertions.
|
|
2903
|
+
*
|
|
2904
|
+
* @returns A sink whose `envelopes` array grows in emission order.
|
|
2905
|
+
*/
|
|
2906
|
+
function createMemorySink() {
|
|
2907
|
+
const envelopes = [];
|
|
2908
|
+
return {
|
|
2909
|
+
envelopes,
|
|
2910
|
+
emit: (envelope) => {
|
|
2911
|
+
envelopes.push(envelope);
|
|
2912
|
+
}
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
//#endregion
|
|
2916
|
+
//#region src/eventsink/ndjson-sink.ts
|
|
2917
|
+
/**
|
|
2918
|
+
* NDJSON sink: one JSON envelope per line. The `write` sink is injected (the CLI
|
|
2919
|
+
* passes stdout) so this file stays neutral and needs no `node:*`.
|
|
2920
|
+
*
|
|
2921
|
+
* @param args.write Receives each line, newline included.
|
|
2922
|
+
* @returns A sink that serialises every envelope it is given.
|
|
2923
|
+
*/
|
|
2924
|
+
function createNdjsonSink(args) {
|
|
2925
|
+
return { emit: (envelope) => args.write(`${JSON.stringify(envelope)}\n`) };
|
|
2926
|
+
}
|
|
2927
|
+
//#endregion
|
|
2928
|
+
//#region src/ports/create-port-resolver.ts
|
|
2929
|
+
/**
|
|
2930
|
+
* Resolve ports from a set of bindings, running capability and shape negotiation
|
|
2931
|
+
* at the moment of resolution.
|
|
2932
|
+
*
|
|
2933
|
+
* @param args.bindings Every port the host chose to bind, with its implementation.
|
|
2934
|
+
* @param args.caps What this host offers, checked against each port's `requires`.
|
|
2935
|
+
* @returns A resolver whose `resolve` throws `VN7002` when the port is unbound,
|
|
2936
|
+
* and whatever `bindPort` raises when the capability or shape check fails.
|
|
2937
|
+
*/
|
|
2938
|
+
function createPortResolver(args) {
|
|
2939
|
+
const byId = new Map(args.bindings.map((binding) => [binding.port.id, binding]));
|
|
2940
|
+
return { resolve: (port) => resolveOne({
|
|
2941
|
+
port,
|
|
2942
|
+
binding: byId.get(port.id),
|
|
2943
|
+
caps: args.caps
|
|
2944
|
+
}) };
|
|
2945
|
+
}
|
|
2946
|
+
function resolveOne(args) {
|
|
2947
|
+
if (!args.binding) throw unbound(args.port.id);
|
|
2948
|
+
return bindPort({
|
|
2949
|
+
port: args.port,
|
|
2950
|
+
impl: args.binding.impl,
|
|
2951
|
+
caps: args.caps
|
|
2952
|
+
});
|
|
2953
|
+
}
|
|
2954
|
+
function unbound(id) {
|
|
2955
|
+
return new VennError({
|
|
2956
|
+
code: "VN7002",
|
|
2957
|
+
message: `No implementation bound for port "${id}".`,
|
|
2958
|
+
detail: { port: id }
|
|
2959
|
+
});
|
|
2960
|
+
}
|
|
2961
|
+
//#endregion
|
|
2962
|
+
//#region src/registry/build-registry.ts
|
|
2963
|
+
/**
|
|
2964
|
+
* Ingest plugins after negotiating each plugin's capabilities against the host.
|
|
2965
|
+
*
|
|
2966
|
+
* @param args.plugins The plugins to index, in load order; later ones win a clash.
|
|
2967
|
+
* @param args.caps The capabilities this host offers.
|
|
2968
|
+
* @returns A registry resolving actions, matchers and namespaces.
|
|
2969
|
+
* @throws VennError `VN2010` when a plugin requires a capability the host lacks.
|
|
2970
|
+
*/
|
|
2971
|
+
function buildRegistry(args) {
|
|
2972
|
+
for (const plugin of args.plugins) assertPluginCaps({
|
|
2973
|
+
plugin,
|
|
2974
|
+
caps: args.caps
|
|
2975
|
+
});
|
|
2976
|
+
return indexPlugins(args.plugins);
|
|
2977
|
+
}
|
|
2978
|
+
function assertPluginCaps(args) {
|
|
2979
|
+
const missing = missingCapabilities({
|
|
2980
|
+
requires: args.plugin.requires ?? [],
|
|
2981
|
+
caps: args.caps
|
|
2982
|
+
});
|
|
2983
|
+
if (missing.length === 0) return;
|
|
2984
|
+
const list = missing.map((cap) => `"${cap}"`).join(", ");
|
|
2985
|
+
throw new VennError({
|
|
2986
|
+
code: "VN2010",
|
|
2987
|
+
message: `Plugin "${args.plugin.name}" requires capability ${list}, absent from this host.`,
|
|
2988
|
+
detail: {
|
|
2989
|
+
plugin: args.plugin.name,
|
|
2990
|
+
missing
|
|
2991
|
+
}
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
function indexPlugins(plugins) {
|
|
2995
|
+
const actions = /* @__PURE__ */ new Map();
|
|
2996
|
+
const matchers = /* @__PURE__ */ new Map();
|
|
2997
|
+
const namespaces = /* @__PURE__ */ new Set();
|
|
2998
|
+
const packages = /* @__PURE__ */ new Map();
|
|
2999
|
+
for (const plugin of plugins) addPlugin({
|
|
3000
|
+
plugin,
|
|
3001
|
+
actions,
|
|
3002
|
+
matchers,
|
|
3003
|
+
namespaces,
|
|
3004
|
+
packages
|
|
3005
|
+
});
|
|
3006
|
+
return {
|
|
3007
|
+
action: ({ namespace, name }) => actions.get(`${namespace}.${name}`),
|
|
3008
|
+
matcher: (name) => matchers.get(name),
|
|
3009
|
+
hasNamespace: (namespace) => namespaces.has(namespace),
|
|
3010
|
+
namespaceOf: (pkg) => packages.get(pkg),
|
|
3011
|
+
actions: () => listActions(actions)
|
|
3012
|
+
};
|
|
3013
|
+
}
|
|
3014
|
+
function listActions(actions) {
|
|
3015
|
+
return [...actions].map(([key, resolved]) => ({
|
|
3016
|
+
namespace: key.slice(0, key.indexOf(".")),
|
|
3017
|
+
name: key.slice(key.indexOf(".") + 1),
|
|
3018
|
+
action: resolved.action
|
|
3019
|
+
}));
|
|
3020
|
+
}
|
|
3021
|
+
function addPlugin(args) {
|
|
3022
|
+
const { plugin, actions, matchers, namespaces, packages } = args;
|
|
3023
|
+
namespaces.add(plugin.namespace);
|
|
3024
|
+
packages.set(plugin.name, plugin.namespace);
|
|
3025
|
+
for (const action of plugin.actions ?? []) actions.set(`${plugin.namespace}.${action.name}`, {
|
|
3026
|
+
plugin,
|
|
3027
|
+
action
|
|
3028
|
+
});
|
|
3029
|
+
for (const matcher of plugin.matchers ?? []) matchers.set(matcher.name, {
|
|
3030
|
+
plugin,
|
|
3031
|
+
matcher
|
|
3032
|
+
});
|
|
3033
|
+
}
|
|
3034
|
+
//#endregion
|
|
3035
|
+
//#region src/context/action-context.ts
|
|
3036
|
+
/** Build the context an action's `run` receives from the host and port resolver. */
|
|
3037
|
+
function createActionContext(args) {
|
|
3038
|
+
return {
|
|
3039
|
+
port: (port) => args.ports.resolve(port),
|
|
3040
|
+
secrets: args.host.secrets,
|
|
3041
|
+
config: args.config ?? {},
|
|
3042
|
+
log: (message) => args.host.log.log({
|
|
3043
|
+
level: "info",
|
|
3044
|
+
message
|
|
3045
|
+
}),
|
|
3046
|
+
redact: () => {},
|
|
3047
|
+
invoke: (fn, values) => invoke(fn, values)
|
|
3048
|
+
};
|
|
3049
|
+
}
|
|
3050
|
+
//#endregion
|
|
3051
|
+
//#region src/run/create-runner.ts
|
|
3052
|
+
/**
|
|
3053
|
+
* Build a runner: negotiate plugins and wire ports once, then reuse them for
|
|
3054
|
+
* every run.
|
|
3055
|
+
*
|
|
3056
|
+
* @param args The host, the plugins, the event sink and the per-run options.
|
|
3057
|
+
* @returns A runner exposing test mode (`run`) and script mode (`script`).
|
|
3058
|
+
* @throws VennError `VN2010` when a plugin requires a capability the host lacks.
|
|
3059
|
+
*/
|
|
3060
|
+
function createRunner(args) {
|
|
3061
|
+
const registry = buildRegistry({
|
|
3062
|
+
plugins: args.plugins,
|
|
3063
|
+
caps: args.host.caps
|
|
3064
|
+
});
|
|
3065
|
+
const resolver = createPortResolver({
|
|
3066
|
+
bindings: args.ports ?? [],
|
|
3067
|
+
caps: args.host.caps
|
|
3068
|
+
});
|
|
3069
|
+
const decorators = createDecoratorSource(args.plugins);
|
|
3070
|
+
const drive = (walk) => (document) => {
|
|
3071
|
+
const expansion = expand({
|
|
3072
|
+
document,
|
|
3073
|
+
decorators,
|
|
3074
|
+
uri: args.uri,
|
|
3075
|
+
imported: args.moduleDecos
|
|
3076
|
+
});
|
|
3077
|
+
return runOnce({
|
|
3078
|
+
args,
|
|
3079
|
+
registry,
|
|
3080
|
+
resolver,
|
|
3081
|
+
document
|
|
3082
|
+
}, walk, expansion.problems);
|
|
3083
|
+
};
|
|
3084
|
+
return {
|
|
3085
|
+
run: drive(runDocument),
|
|
3086
|
+
script: drive(runScript)
|
|
3087
|
+
};
|
|
3088
|
+
}
|
|
3089
|
+
async function runOnce(input, walk, problems) {
|
|
3090
|
+
const run = newRunId({
|
|
3091
|
+
clock: input.args.host.clock,
|
|
3092
|
+
random: input.args.host.random
|
|
3093
|
+
});
|
|
3094
|
+
const engine = buildEngine(input, run);
|
|
3095
|
+
await absorbExit(engine, () => walk(engine, input.document));
|
|
3096
|
+
return {
|
|
3097
|
+
run,
|
|
3098
|
+
problems,
|
|
3099
|
+
passed: engine.result.passed,
|
|
3100
|
+
failed: engine.result.failed,
|
|
3101
|
+
exitCode: engine.exit
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
function buildEngine(input, run) {
|
|
3105
|
+
const { args, registry } = input;
|
|
3106
|
+
return {
|
|
3107
|
+
registry,
|
|
3108
|
+
emitter: createEmitter({
|
|
3109
|
+
sink: args.sink,
|
|
3110
|
+
run,
|
|
3111
|
+
clock: args.host.clock
|
|
3112
|
+
}),
|
|
3113
|
+
clock: args.host.clock,
|
|
3114
|
+
lock: args.host.lock,
|
|
3115
|
+
uri: args.uri ?? "memory://inline.vn",
|
|
3116
|
+
result: {
|
|
3117
|
+
passed: 0,
|
|
3118
|
+
failed: 0
|
|
3119
|
+
},
|
|
3120
|
+
flaky: /* @__PURE__ */ new Map(),
|
|
3121
|
+
filter: args.filter ?? {},
|
|
3122
|
+
bail: args.bail,
|
|
3123
|
+
cleanup: args.cleanup ?? createCleanupList(),
|
|
3124
|
+
...documentParts(input)
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
/** Everything derived from the document being run: config, fragments, aliases. */
|
|
3128
|
+
function documentParts(input) {
|
|
3129
|
+
const { args, registry, resolver, document } = input;
|
|
3130
|
+
const env = args.env ?? {};
|
|
3131
|
+
const config = collectConfig(document, env);
|
|
3132
|
+
return {
|
|
3133
|
+
ctx: createActionContext({
|
|
3134
|
+
host: args.host,
|
|
3135
|
+
ports: resolver,
|
|
3136
|
+
config
|
|
3137
|
+
}),
|
|
3138
|
+
fragments: mergeFragments(collectFragments(document), args.moduleFragments),
|
|
3139
|
+
imports: args.modules,
|
|
3140
|
+
aliases: collectAliases(document, registry),
|
|
3141
|
+
env
|
|
3142
|
+
};
|
|
3143
|
+
}
|
|
3144
|
+
/** Local fragments take precedence over imported ones of the same name. */
|
|
3145
|
+
function mergeFragments(local, imported) {
|
|
3146
|
+
if (!imported) return local;
|
|
3147
|
+
const merged = new Map(imported);
|
|
3148
|
+
for (const [name, fragment] of local) merged.set(name, fragment);
|
|
3149
|
+
return merged;
|
|
3150
|
+
}
|
|
3151
|
+
//#endregion
|
|
3152
|
+
//#region src/run/resolve-imports.ts
|
|
3153
|
+
/**
|
|
3154
|
+
* Walk the import graph from one document, parsing every `.vn` file it reaches
|
|
3155
|
+
* and loading every package it names. A file already seen is skipped, so a cycle
|
|
3156
|
+
* ends rather than loops. A file that cannot be read is skipped in silence:
|
|
3157
|
+
* whoever tried to read it reports the failure.
|
|
3158
|
+
*
|
|
3159
|
+
* @param args The entry document, its URI, the source reader and the optional
|
|
3160
|
+
* package loader.
|
|
3161
|
+
* @returns The exported fragments and decos, the packages, the loaded npm
|
|
3162
|
+
* modules and every parsed document, keyed by resolved URI.
|
|
3163
|
+
*/
|
|
3164
|
+
async function resolveImports(args) {
|
|
3165
|
+
const found = {
|
|
3166
|
+
fragments: /* @__PURE__ */ new Map(),
|
|
3167
|
+
decos: /* @__PURE__ */ new Map()
|
|
3168
|
+
};
|
|
3169
|
+
const packages = /* @__PURE__ */ new Set();
|
|
3170
|
+
const modules = /* @__PURE__ */ new Map();
|
|
3171
|
+
const npm = /* @__PURE__ */ new Map();
|
|
3172
|
+
collectUses(args.document, packages);
|
|
3173
|
+
await loadInto({
|
|
3174
|
+
document: args.document,
|
|
3175
|
+
uri: args.uri,
|
|
3176
|
+
io: args.io,
|
|
3177
|
+
npmLoader: args.npm,
|
|
3178
|
+
found,
|
|
3179
|
+
packages,
|
|
3180
|
+
modules,
|
|
3181
|
+
npm,
|
|
3182
|
+
seen: /* @__PURE__ */ new Set([args.uri])
|
|
3183
|
+
});
|
|
3184
|
+
return {
|
|
3185
|
+
...found,
|
|
3186
|
+
packages,
|
|
3187
|
+
modules,
|
|
3188
|
+
npm
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
/**
|
|
3192
|
+
* Add the packages a file declares with `use` to `into`. Collected across the
|
|
3193
|
+
* whole graph: a fragment imported from elsewhere calls the verbs *its* file
|
|
3194
|
+
* asked for.
|
|
3195
|
+
*
|
|
3196
|
+
* @param document The file to read `use` declarations from.
|
|
3197
|
+
* @param into The accumulating set of package specifiers, mutated in place.
|
|
3198
|
+
*/
|
|
3199
|
+
function collectUses(document, into) {
|
|
3200
|
+
for (const decl of document.imports) if (isUseDecl(decl)) into.add(decl.pkg);
|
|
3201
|
+
}
|
|
3202
|
+
async function loadInto(state) {
|
|
3203
|
+
for (const decl of state.document.imports) {
|
|
3204
|
+
if (!isValueImport(decl)) continue;
|
|
3205
|
+
if (isPackageSpecifier(decl.path)) await loadPackage(state, decl.path);
|
|
3206
|
+
else await loadModule(state, decl.path);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
/**
|
|
3210
|
+
* A package that was installed, loaded once however many files name it.
|
|
3211
|
+
*
|
|
3212
|
+
* A host with no loader has no packages, which is the state of every run in a
|
|
3213
|
+
* Web Worker. The import is left unbound and the name reports itself as unknown,
|
|
3214
|
+
* rather than the load failing somewhere nobody can see.
|
|
3215
|
+
*/
|
|
3216
|
+
async function loadPackage(state, spec) {
|
|
3217
|
+
if (state.npm.has(spec) || !state.npmLoader) return;
|
|
3218
|
+
const found = await state.npmLoader.load(spec).catch(() => void 0);
|
|
3219
|
+
if (found) state.npm.set(spec, found);
|
|
3220
|
+
}
|
|
3221
|
+
async function loadModule(state, spec) {
|
|
3222
|
+
const target = state.io.resolve(state.uri, spec);
|
|
3223
|
+
if (state.seen.has(target)) return;
|
|
3224
|
+
state.seen.add(target);
|
|
3225
|
+
const source = await state.io.read(target).catch(() => void 0);
|
|
3226
|
+
if (source === void 0) return;
|
|
3227
|
+
const { ast } = parse(source, { uri: target });
|
|
3228
|
+
state.modules.set(target, ast);
|
|
3229
|
+
collectExports({
|
|
3230
|
+
document: ast,
|
|
3231
|
+
uri: target,
|
|
3232
|
+
found: state.found
|
|
3233
|
+
});
|
|
3234
|
+
collectUses(ast, state.packages);
|
|
3235
|
+
await loadInto({
|
|
3236
|
+
...state,
|
|
3237
|
+
document: ast,
|
|
3238
|
+
uri: target
|
|
3239
|
+
});
|
|
3240
|
+
}
|
|
3241
|
+
/** Everything a file marked `pub`. A `deco` keeps its file: its faults are its own. */
|
|
3242
|
+
function collectExports(args) {
|
|
3243
|
+
for (const decl of args.document.decls) {
|
|
3244
|
+
if (isFragmentDecl(decl) && decl.export) args.found.fragments.set(decl.name, decl);
|
|
3245
|
+
if (isDecoDecl(decl) && decl.export) args.found.decos.set(decl.name, {
|
|
3246
|
+
decl,
|
|
3247
|
+
uri: args.uri
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
//#endregion
|
|
3252
|
+
//#region src/types/create-type-catalog.ts
|
|
3253
|
+
/**
|
|
3254
|
+
* Turn what the loaded plugins publish into what the checker can ask.
|
|
3255
|
+
*
|
|
3256
|
+
* This is the seam the core deliberately does not cross: `@venn-lang/core` knows
|
|
3257
|
+
* nothing about plugins and gets told. Names are qualified here, once: a plugin
|
|
3258
|
+
* says `Request` and a flow writes `http.Request`.
|
|
3259
|
+
*
|
|
3260
|
+
* @param plugins The plugins loaded for this run.
|
|
3261
|
+
* @returns A catalog answering a type name or an action's signature.
|
|
3262
|
+
*/
|
|
3263
|
+
function createTypeCatalog(plugins) {
|
|
3264
|
+
const published = {
|
|
3265
|
+
types: /* @__PURE__ */ new Map(),
|
|
3266
|
+
signatures: /* @__PURE__ */ new Map()
|
|
3267
|
+
};
|
|
3268
|
+
for (const plugin of plugins) collect(plugin, published);
|
|
3269
|
+
const reader = createReader(published);
|
|
3270
|
+
return {
|
|
3271
|
+
typeOf: (name) => reader.read(published.types, name),
|
|
3272
|
+
signatureOf: (target) => asFn(reader.read(published.signatures, target))
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
function collect(plugin, into) {
|
|
3276
|
+
for (const [name, spec] of Object.entries(plugin.typeDefs ?? {})) into.types.set(`${plugin.namespace}.${name}`, spec);
|
|
3277
|
+
for (const action of plugin.actions ?? []) {
|
|
3278
|
+
const key = `${plugin.namespace}.${action.name}`;
|
|
3279
|
+
if (action.signature) into.signatures.set(key, action.signature);
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
/**
|
|
3283
|
+
* Read a published spec once and keep the result.
|
|
3284
|
+
*
|
|
3285
|
+
* The same `http.Request` has to be the same object every time it is asked for:
|
|
3286
|
+
* inference writes into what it is handed, and two copies would drift apart in
|
|
3287
|
+
* the middle of a file. `open` guards a type that refers to itself, so the
|
|
3288
|
+
* second visit answers "unknown" rather than recurring forever.
|
|
3289
|
+
*/
|
|
3290
|
+
function createReader(published) {
|
|
3291
|
+
const cache = /* @__PURE__ */ new Map();
|
|
3292
|
+
const open = /* @__PURE__ */ new Set();
|
|
3293
|
+
const read = (from, name) => {
|
|
3294
|
+
const cached = cache.get(name);
|
|
3295
|
+
if (cached) return cached;
|
|
3296
|
+
const spec = from.get(name);
|
|
3297
|
+
if (!spec || open.has(name)) return void 0;
|
|
3298
|
+
open.add(name);
|
|
3299
|
+
const type = specToType(spec, (ref) => read(published.types, ref));
|
|
3300
|
+
open.delete(name);
|
|
3301
|
+
cache.set(name, type);
|
|
3302
|
+
return type;
|
|
3303
|
+
};
|
|
3304
|
+
return { read };
|
|
3305
|
+
}
|
|
3306
|
+
function asFn(type) {
|
|
3307
|
+
if (!type) return void 0;
|
|
3308
|
+
const pruned = prune(type);
|
|
3309
|
+
return pruned.kind === "fn" ? pruned : void 0;
|
|
3310
|
+
}
|
|
3311
|
+
//#endregion
|
|
3312
|
+
export { EventSinkPort, buildRegistry, builtinDecorators, checkDocument, checkFragmentCall, checkImports, collectFragments, collectUses, createCleanupList, createDecoratorSource, createEmitter, createMemorySink, createNdjsonSink, createPortResolver, createRunner, createScope, createTypeCatalog, matchesTitle, newRunId, resolveImports };
|
|
3313
|
+
|
|
3314
|
+
//# sourceMappingURL=index.js.map
|