@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.
Files changed (104) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +158 -0
  3. package/dist/index.d.ts +453 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +3314 -0
  6. package/dist/index.js.map +1 -0
  7. package/package.json +56 -0
  8. package/src/check/check-calls.ts +82 -0
  9. package/src/check/check-deco-body.ts +77 -0
  10. package/src/check/check-document.ts +106 -0
  11. package/src/check/check-env.ts +96 -0
  12. package/src/check/check-fragment-call.ts +27 -0
  13. package/src/check/check-imports.ts +82 -0
  14. package/src/check/check-interpolation.ts +58 -0
  15. package/src/check/check-options.ts +18 -0
  16. package/src/check/check-uncalled.ts +42 -0
  17. package/src/check/check.types.ts +30 -0
  18. package/src/check/index.ts +4 -0
  19. package/src/context/action-context.ts +20 -0
  20. package/src/context/index.ts +1 -0
  21. package/src/decorators/builtin-decorators.ts +87 -0
  22. package/src/decorators/create-decorator-source.ts +25 -0
  23. package/src/decorators/index.ts +2 -0
  24. package/src/emit/create-emitter.ts +16 -0
  25. package/src/emit/emitter.types.ts +6 -0
  26. package/src/emit/index.ts +3 -0
  27. package/src/emit/run-id.ts +11 -0
  28. package/src/eventsink/event-sink.port.ts +13 -0
  29. package/src/eventsink/event-sink.types.ts +11 -0
  30. package/src/eventsink/index.ts +4 -0
  31. package/src/eventsink/memory-sink.ts +17 -0
  32. package/src/eventsink/ndjson-sink.ts +14 -0
  33. package/src/index.ts +14 -0
  34. package/src/ports/create-port-resolver.ts +39 -0
  35. package/src/ports/index.ts +2 -0
  36. package/src/ports/port-resolver.types.ts +12 -0
  37. package/src/registry/build-registry.ts +76 -0
  38. package/src/registry/index.ts +2 -0
  39. package/src/registry/registry.types.ts +24 -0
  40. package/src/run/create-runner.ts +103 -0
  41. package/src/run/index.ts +4 -0
  42. package/src/run/resolve-imports.ts +166 -0
  43. package/src/run/runner.types.ts +67 -0
  44. package/src/scheduler/absorb-exit.ts +19 -0
  45. package/src/scheduler/aliases.ts +44 -0
  46. package/src/scheduler/annotations.ts +48 -0
  47. package/src/scheduler/base-scope.ts +25 -0
  48. package/src/scheduler/bind-globals.ts +55 -0
  49. package/src/scheduler/bind-imports.ts +130 -0
  50. package/src/scheduler/bind-namespaces.ts +61 -0
  51. package/src/scheduler/bind-prelude.ts +11 -0
  52. package/src/scheduler/block-plan.ts +66 -0
  53. package/src/scheduler/branch-engine.ts +13 -0
  54. package/src/scheduler/call-params.ts +140 -0
  55. package/src/scheduler/cleanup.types.ts +18 -0
  56. package/src/scheduler/collect.ts +60 -0
  57. package/src/scheduler/concurrency.ts +16 -0
  58. package/src/scheduler/create-cleanup-list.ts +17 -0
  59. package/src/scheduler/declared-arity.ts +13 -0
  60. package/src/scheduler/engine.types.ts +50 -0
  61. package/src/scheduler/filter.ts +5 -0
  62. package/src/scheduler/filter.types.ts +9 -0
  63. package/src/scheduler/flaky.ts +32 -0
  64. package/src/scheduler/flaky.types.ts +7 -0
  65. package/src/scheduler/index.ts +16 -0
  66. package/src/scheduler/invocation.ts +103 -0
  67. package/src/scheduler/local-call.ts +28 -0
  68. package/src/scheduler/node-span.ts +29 -0
  69. package/src/scheduler/opts-text.ts +10 -0
  70. package/src/scheduler/opts.ts +14 -0
  71. package/src/scheduler/pending.types.ts +9 -0
  72. package/src/scheduler/run-action.ts +163 -0
  73. package/src/scheduler/run-around.ts +23 -0
  74. package/src/scheduler/run-attempts.ts +105 -0
  75. package/src/scheduler/run-bindings.ts +67 -0
  76. package/src/scheduler/run-block.ts +65 -0
  77. package/src/scheduler/run-document.ts +181 -0
  78. package/src/scheduler/run-expect.ts +48 -0
  79. package/src/scheduler/run-flow.ts +69 -0
  80. package/src/scheduler/run-foreach.ts +148 -0
  81. package/src/scheduler/run-group.ts +9 -0
  82. package/src/scheduler/run-if.ts +21 -0
  83. package/src/scheduler/run-lifecycle.ts +86 -0
  84. package/src/scheduler/run-matcher.ts +99 -0
  85. package/src/scheduler/run-parallel.ts +68 -0
  86. package/src/scheduler/run-prologue.ts +59 -0
  87. package/src/scheduler/run-race.ts +20 -0
  88. package/src/scheduler/run-repeat.ts +54 -0
  89. package/src/scheduler/run-run.ts +41 -0
  90. package/src/scheduler/run-script.ts +48 -0
  91. package/src/scheduler/run-statements.ts +107 -0
  92. package/src/scheduler/run-step.ts +76 -0
  93. package/src/scheduler/run-try.ts +34 -0
  94. package/src/scheduler/run-while.ts +50 -0
  95. package/src/scheduler/script-lifecycle.ts +47 -0
  96. package/src/scheduler/settled.ts +20 -0
  97. package/src/scheduler/signals.ts +33 -0
  98. package/src/scheduler/target.ts +30 -0
  99. package/src/scheduler/unknown-option.ts +87 -0
  100. package/src/scope/create-scope.ts +67 -0
  101. package/src/scope/index.ts +2 -0
  102. package/src/scope/scope.types.ts +12 -0
  103. package/src/types/create-type-catalog.ts +70 -0
  104. package/src/types/index.ts +1 -0
@@ -0,0 +1,9 @@
1
+ /**
2
+ * What a statement leaves behind: nothing, or work still in flight.
3
+ *
4
+ * Most statements are pure evaluation (a `let`, a `return`, a comparison) and an
5
+ * `async` handler would wrap each one in a promise and a microtask. Returning
6
+ * `undefined` when there is nothing to await lets the caller skip that, which is
7
+ * what keeps a loop over 50k items from paying five promises an iteration.
8
+ */
9
+ export type Pending = void | Promise<void>;
@@ -0,0 +1,163 @@
1
+ import { ConsolePort, VennError } from "@venn-lang/contracts";
2
+ import {
3
+ type ActionCall,
4
+ type AstNode,
5
+ callArgs,
6
+ display,
7
+ evaluate,
8
+ invoke,
9
+ splitCall,
10
+ } from "@venn-lang/core";
11
+ import type { ActionDefinition, ActionInput } from "@venn-lang/sdk";
12
+ import type { Scope } from "../scope/index.js";
13
+ import { callParams } from "./call-params.js";
14
+ import { takes } from "./declared-arity.js";
15
+ import type { Engine } from "./engine.types.js";
16
+ import type { Invocation } from "./invocation.js";
17
+ import { localCallee } from "./local-call.js";
18
+ import { settle } from "./settled.js";
19
+ import { ExitSignal } from "./signals.js";
20
+ import { PRELUDE, resolveTarget } from "./target.js";
21
+
22
+ /**
23
+ * Resolve, invoke and time an action call.
24
+ *
25
+ * @returns Whatever the action produced. The caller names it: there is no
26
+ * implicit `res` for a reader to know about.
27
+ * @throws VennError `VN2003` when no plugin provides the target.
28
+ */
29
+ export async function runAction(engine: Engine, call: Invocation, scope: Scope): Promise<unknown> {
30
+ if (PRELUDE.has(call.target)) return runPrelude(engine, call, scope);
31
+ // `conn.close()` is a method on something the program holds. A name it bound
32
+ // beats a namespace of the same name, so this is asked first.
33
+ const callee = localCallee(call.target, scope);
34
+ if (callee !== undefined) return callMethod(callee, call, scope);
35
+ const { namespace, name } = resolveTarget(call.target, engine.aliases);
36
+ const resolved = engine.registry.action({ namespace, name });
37
+ if (!resolved) throw unknownAction(call.target);
38
+ engine.emitter.emit({ kind: "action.started", data: { namespace, action: name } });
39
+ const start = engine.clock.now();
40
+ const value = await resolved.action.run(
41
+ engine.ctx,
42
+ await buildInput({ action: resolved.action, call, scope, uri: engine.uri }),
43
+ );
44
+ const durationMs = engine.clock.now() - start;
45
+ engine.emitter.emit({
46
+ kind: "action.finished",
47
+ data: { namespace, action: name, status: "passed", durationMs },
48
+ });
49
+ return value;
50
+ }
51
+
52
+ /** Call a value the program holds, and wait for it as any other call is waited for. */
53
+ async function callMethod(callee: unknown, call: Invocation, scope: Scope): Promise<unknown> {
54
+ const values = await Promise.all(call.args.map((expr) => settle(evaluate(expr, scope))));
55
+ return settle(invoke(callee, values));
56
+ }
57
+
58
+ /**
59
+ * An action written as a bare statement: run it, drop what it returned.
60
+ *
61
+ * The arguments are normalised here because the two spellings, `conn.close()`
62
+ * and `http.get "url"`, put them in different places on the node.
63
+ */
64
+ export async function runActionStatement(
65
+ engine: Engine,
66
+ call: ActionCall,
67
+ scope: Scope,
68
+ ): Promise<void> {
69
+ await runAction(engine, { ...call, args: callArgs(call) }, scope);
70
+ }
71
+
72
+ async function buildInput(args: {
73
+ action: ActionDefinition;
74
+ call: Invocation;
75
+ scope: Scope;
76
+ uri: string;
77
+ }): Promise<ActionInput<unknown>> {
78
+ // The trailing `{ … }` is the options in both spellings, but only the bareword
79
+ // one puts it where the parser can label it. Splitting by declared arity is
80
+ // what stops `http.get(url, { headers })` sending no headers at all.
81
+ const split = splitCall(args.call.args, takes(args.action));
82
+ const opts = args.call.opts ?? split.opts;
83
+ const positional = await Promise.all(
84
+ split.args.map((expr) => settle(evaluate(expr, args.scope))),
85
+ );
86
+ const raw = await settle(opts ? evaluate(opts, args.scope) : {});
87
+ const schema = args.action.params;
88
+ const site = siteOf(args.call);
89
+ return {
90
+ args: positional,
91
+ params: callParams({ schema, opts, raw, site, uri: args.uri }),
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Where the call is written. A call spelled as a statement is its own node; one
97
+ * rescued out of a `let` carries the statement that spells it, since the
98
+ * invocation itself is a plain object with no place in the source.
99
+ */
100
+ function siteOf(call: Invocation): AstNode {
101
+ return call.node ?? (call as unknown as AstNode);
102
+ }
103
+
104
+ function unknownAction(target: string): VennError {
105
+ return new VennError({
106
+ code: "VN2003",
107
+ message: `Unknown action "${target}".`,
108
+ detail: { target },
109
+ });
110
+ }
111
+
112
+ /** Prelude verbs, available without `use` (§12): print, log, wait, skip, fail, exit. */
113
+ async function runPrelude(engine: Engine, call: Invocation, scope: Scope): Promise<void> {
114
+ const args = await Promise.all(call.args.map((arg) => settle(evaluate(arg, scope))));
115
+ if (call.target === "print") return printLine(engine, args);
116
+ if (call.target === "log") return logLine(engine, args);
117
+ const message = String(args[0] ?? "");
118
+ if (call.target === "wait") await engine.clock.sleep(waitMs(args[0]));
119
+ else if (call.target === "skip") skipLog(engine, message);
120
+ else if (call.target === "exit") throw new ExitSignal(exitCode(args[0]));
121
+ else if (call.target === "fail") throw failError(message);
122
+ }
123
+
124
+ /**
125
+ * `print x y`: the program's own output, on standard output. Distinct from
126
+ * `log`, which records into the event stream a reporter reads.
127
+ */
128
+ function printLine(engine: Engine, args: readonly unknown[]): void {
129
+ engine.ctx.port(ConsolePort).write(`${line(args)}\n`);
130
+ }
131
+
132
+ /** `log a b c`: every argument, spaced, objects shown as JSON not `[object Object]`. */
133
+ function logLine(engine: Engine, args: readonly unknown[]): void {
134
+ engine.emitter.emit({ kind: "log", data: { level: "info", message: line(args) } });
135
+ }
136
+
137
+ function line(args: readonly unknown[]): string {
138
+ return args.map(display).join(" ");
139
+ }
140
+
141
+ /**
142
+ * The code `exit` leaves with. Anything the machine cannot use as one still
143
+ * ended badly, so `exit "boom"` leaves with 1 and never with 0, which would read
144
+ * as success to whatever is waiting on this process.
145
+ */
146
+ function exitCode(value: unknown): number {
147
+ const code = Math.trunc(Number(value ?? 0));
148
+ return Number.isFinite(code) ? code : 1;
149
+ }
150
+
151
+ function skipLog(engine: Engine, message: string): void {
152
+ engine.emitter.emit({ kind: "log", data: { level: "warn", message: `skipped: ${message}` } });
153
+ }
154
+
155
+ function waitMs(value: unknown): number {
156
+ if (typeof value === "number") return value;
157
+ const duration = value as { kind?: string; ms?: number };
158
+ return duration?.kind === "duration" ? (duration.ms ?? 0) : 0;
159
+ }
160
+
161
+ function failError(message: string): VennError {
162
+ return new VennError({ code: "VN6002", message: message || "fail" });
163
+ }
@@ -0,0 +1,23 @@
1
+ import { invoke, readDecorations } from "@venn-lang/core";
2
+
3
+ /**
4
+ * Run the `.before` and `.after` closures a `deco` left on a flow or a step.
5
+ *
6
+ * The language has no syntax for "around this body", so the verbs leave a fact
7
+ * behind and this is where it is honoured. `.after` runs from a `finally`,
8
+ * because a hook that only runs when nothing went wrong is the one case it was
9
+ * written for.
10
+ */
11
+ export async function runAround(node: object, body: () => unknown): Promise<void> {
12
+ const around = readDecorations(node);
13
+ if (!around) {
14
+ await body();
15
+ return;
16
+ }
17
+ for (const fn of around.before) invoke(fn, []);
18
+ try {
19
+ await body();
20
+ } finally {
21
+ for (const fn of around.after) invoke(fn, []);
22
+ }
23
+ }
@@ -0,0 +1,105 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import type { Annotation } from "@venn-lang/core";
3
+ import type { Scope } from "../scope/index.js";
4
+ import { type RetrySpec, readRetry, readTimeout } from "./annotations.js";
5
+ import type { Engine } from "./engine.types.js";
6
+ import { isControlSignal } from "./signals.js";
7
+
8
+ interface AnnotatedNode {
9
+ annotations: Annotation[];
10
+ }
11
+
12
+ /** Run a step/flow body applying its `@timeout` and `@retry` annotations. */
13
+ export async function runWithAnnotations(args: {
14
+ engine: Engine;
15
+ node: AnnotatedNode;
16
+ scope: Scope;
17
+ title: string;
18
+ run: () => Promise<void>;
19
+ }): Promise<void> {
20
+ const timeout = readTimeout(args.node);
21
+ const retry = readRetry(args.node);
22
+ const attempt = (): Promise<void> => withTimeout(timeout, args.run);
23
+ if (!retry || retry.attempts === 0) return attempt();
24
+ await withRetry({ engine: args.engine, retry, title: args.title, run: attempt });
25
+ }
26
+
27
+ /** Hold the named mutex (`@lock`/`@serial`) around `run`, releasing on exit. */
28
+ export async function withLock(
29
+ engine: Engine,
30
+ name: string | undefined,
31
+ run: () => Promise<void>,
32
+ ): Promise<void> {
33
+ if (!name) return run();
34
+ const release = await engine.lock.acquire(name);
35
+ try {
36
+ await run();
37
+ } finally {
38
+ release();
39
+ }
40
+ }
41
+
42
+ async function withTimeout(timeoutMs: number | undefined, run: () => Promise<void>): Promise<void> {
43
+ if (timeoutMs === undefined) return run();
44
+ let timer: ReturnType<typeof setTimeout> | undefined;
45
+ const guard = new Promise<never>((_resolve, reject) => {
46
+ timer = setTimeout(() => reject(timeoutError(timeoutMs)), timeoutMs);
47
+ });
48
+ try {
49
+ await Promise.race([run(), guard]);
50
+ } finally {
51
+ if (timer) clearTimeout(timer);
52
+ }
53
+ }
54
+
55
+ async function withRetry(args: {
56
+ engine: Engine;
57
+ retry: RetrySpec;
58
+ title: string;
59
+ run: () => Promise<void>;
60
+ }): Promise<void> {
61
+ const total = args.retry.attempts + 1;
62
+ for (let i = 1; i <= total; i++) {
63
+ const snapshot = { ...args.engine.result };
64
+ const outcome = await attempt(args.engine, args.run);
65
+ if (outcome.ok) return;
66
+ if (i >= total) {
67
+ if (outcome.error) throw outcome.error;
68
+ return;
69
+ }
70
+ Object.assign(args.engine.result, snapshot);
71
+ emitRetrying({ engine: args.engine, title: args.title, attempt: i });
72
+ await args.engine.clock.sleep(backoff(args.retry, i));
73
+ }
74
+ }
75
+
76
+ interface AttemptOutcome {
77
+ ok: boolean;
78
+ error?: unknown;
79
+ }
80
+
81
+ async function attempt(engine: Engine, run: () => Promise<void>): Promise<AttemptOutcome> {
82
+ const before = engine.result.failed;
83
+ try {
84
+ await run();
85
+ return { ok: engine.result.failed === before };
86
+ } catch (error) {
87
+ if (isControlSignal(error)) throw error;
88
+ return { ok: false, error };
89
+ }
90
+ }
91
+
92
+ function emitRetrying(args: { engine: Engine; title: string; attempt: number }): void {
93
+ args.engine.emitter.emit({
94
+ kind: "flow.retrying",
95
+ data: { title: args.title, attempt: args.attempt, reason: "previous attempt failed" },
96
+ });
97
+ }
98
+
99
+ function backoff(retry: RetrySpec, attempt: number): number {
100
+ return retry.backoffMs * retry.factor ** (attempt - 1);
101
+ }
102
+
103
+ function timeoutError(ms: number): VennError {
104
+ return new VennError({ code: "VN8001", message: `Timed out after ${ms}ms.` });
105
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ type CaptureStmt,
3
+ evaluate,
4
+ isNamespaceValue,
5
+ type LetStmt,
6
+ type ReturnStmt,
7
+ } from "@venn-lang/core";
8
+ import type { Scope } from "../scope/index.js";
9
+ import type { Engine } from "./engine.types.js";
10
+ import { actionCall, type Invocation, invocationOf } from "./invocation.js";
11
+ import type { Pending } from "./pending.types.js";
12
+ import { runAction } from "./run-action.js";
13
+ import { isPending } from "./settled.js";
14
+ import { ReturnSignal } from "./signals.js";
15
+ import { resolveTarget } from "./target.js";
16
+
17
+ /**
18
+ * `let plan = "pro"` binds a value; `let auth = http.post url { … }` binds what
19
+ * the action returned. Naming the result is what makes it readable: there is no
20
+ * implicit variable to know about.
21
+ */
22
+ export function runLet(engine: Engine, stmt: LetStmt, scope: Scope): Pending {
23
+ const call = invocationOf(stmt) ?? impliedCall(engine, stmt, scope);
24
+ // Only a call suspends. Binding a plain expression is the common case, and
25
+ // an `async` here would cost a promise per iteration of every loop.
26
+ if (!call) return bind(stmt.name, evaluate(stmt.value, scope), scope);
27
+ return runAction(engine, call, scope).then((value) => scope.set(stmt.name, value));
28
+ }
29
+
30
+ /**
31
+ * `let id = data.faker.uuid` or `let id = data.faker.uuid()`: a dotted path
32
+ * naming a plugin action is a call, exactly as it would be as a statement.
33
+ *
34
+ * A binding in scope always wins, so `let code = res.status` reads a field and
35
+ * `let parts = name.split(" ")` calls a method. Neither turns into I/O, whatever
36
+ * the registry happens to hold.
37
+ */
38
+ function impliedCall(engine: Engine, stmt: LetStmt, scope: Scope): Invocation | undefined {
39
+ const call = actionCall(stmt.value);
40
+ const dot = call ? call.target.indexOf(".") : -1;
41
+ if (!call || dot < 0 || bound(scope, call.target.slice(0, dot))) return undefined;
42
+ const resolved = engine.registry.action(resolveTarget(call.target, engine.aliases));
43
+ return resolved ? { target: call.target, args: call.args, node: stmt } : undefined;
44
+ }
45
+
46
+ /** A name the user bound. A plugin namespace in scope is not one, so the verb still calls. */
47
+ function bound(scope: Scope, name: string): boolean {
48
+ const value = scope.lookup(name);
49
+ return value !== undefined && !isNamespaceValue(value);
50
+ }
51
+
52
+ /** Bind now, or once the value it is waiting on arrives. */
53
+ function bind(name: string, value: unknown, scope: Scope): Pending {
54
+ if (isPending(value)) return value.then((settled) => scope.set(name, settled));
55
+ return void scope.set(name, value);
56
+ }
57
+
58
+ /** `capture` is removed in favour of `let`; still run, so old files keep working. */
59
+ export function runCapture(stmt: CaptureStmt, scope: Scope): void {
60
+ scope.set(stmt.name, evaluate(stmt.value, scope));
61
+ }
62
+
63
+ /** `return expr`: unwind to the enclosing fragment with the value. */
64
+ export function runReturn(stmt: ReturnStmt, scope: Scope): never {
65
+ const value = stmt.value ? evaluate(stmt.value, scope) : undefined;
66
+ throw new ReturnSignal(value);
67
+ }
@@ -0,0 +1,65 @@
1
+ import { type Block, isLifecycleDecl, type LifecycleDecl, type Statement } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import { type BlockPlan, planOf, type Step } from "./block-plan.js";
4
+ import type { Engine } from "./engine.types.js";
5
+ import type { Pending } from "./pending.types.js";
6
+ import { runStatement } from "./run-statements.js";
7
+ import { CancelSignal } from "./signals.js";
8
+
9
+ /**
10
+ * Run a block's statements in order, running any `defer { … }` bodies LIFO on
11
+ * the way out, including on error or a control-flow signal.
12
+ */
13
+ export function runBlock(engine: Engine, block: Block, scope: Scope): Pending {
14
+ const plan = planOf(block);
15
+ if (plan.defers) return withDefers(engine, block, scope);
16
+ return runSteps(engine, plan.steps, scope);
17
+ }
18
+
19
+ /** The walk over a plan, reusable by a loop that hoists `planOf` out of it. */
20
+ export function runSteps(engine: Engine, steps: readonly Step[], scope: Scope): Pending {
21
+ for (let at = 0; at < steps.length; at += 1) {
22
+ if (engine.signal?.aborted) throw new CancelSignal();
23
+ const pending = (steps[at] as Step)(engine, scope);
24
+ if (pending) return resume(engine, steps, at + 1, scope, pending);
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ async function resume(
30
+ engine: Engine,
31
+ steps: readonly Step[],
32
+ from: number,
33
+ scope: Scope,
34
+ pending: Promise<void>,
35
+ ): Promise<void> {
36
+ await pending;
37
+ for (let at = from; at < steps.length; at += 1) {
38
+ if (engine.signal?.aborted) throw new CancelSignal();
39
+ const next = (steps[at] as Step)(engine, scope);
40
+ if (next) await next;
41
+ }
42
+ }
43
+
44
+ async function withDefers(engine: Engine, block: Block, scope: Scope): Promise<void> {
45
+ const defers: Block[] = [];
46
+ try {
47
+ for (const stmt of block.stmts) {
48
+ if (isDefer(stmt)) {
49
+ defers.unshift(stmt.body);
50
+ continue;
51
+ }
52
+ const pending = runStatement(engine, stmt, scope);
53
+ if (pending) await pending;
54
+ }
55
+ } finally {
56
+ const cleanup: Engine = { ...engine, signal: undefined };
57
+ for (const body of defers) await runBlock(cleanup, body, scope.child());
58
+ }
59
+ }
60
+
61
+ function isDefer(stmt: Statement): stmt is LifecycleDecl {
62
+ return isLifecycleDecl(stmt) && stmt.hook === "defer";
63
+ }
64
+
65
+ export type { BlockPlan };
@@ -0,0 +1,181 @@
1
+ import {
2
+ type Document,
3
+ evaluate,
4
+ type FlowDecl,
5
+ isFlowDecl,
6
+ isMatrixDecl,
7
+ isStepDecl,
8
+ type PlannedStep,
9
+ type RunPlan,
10
+ } from "@venn-lang/core";
11
+ import { createScope, type Scope } from "../scope/index.js";
12
+ import { absorbExit } from "./absorb-exit.js";
13
+ import { hasAnnotation, readTags } from "./annotations.js";
14
+ import { createBaseScope } from "./base-scope.js";
15
+ import { bindGlobals } from "./bind-globals.js";
16
+ import { bindImports } from "./bind-imports.js";
17
+ import { collectHooks, type SuiteHooks } from "./collect.js";
18
+ import type { Engine } from "./engine.types.js";
19
+ import { matchesTitle } from "./filter.js";
20
+ import { settleFlaky } from "./flaky.js";
21
+ import { runFlow } from "./run-flow.js";
22
+ import { runHooks } from "./run-lifecycle.js";
23
+ import { runPrologue, runTeardowns } from "./run-prologue.js";
24
+
25
+ /**
26
+ * Walk a document: setup once, then for each `matrix` variant bind `env`/`matrix`
27
+ * globals, open resources, run flows (with before/afterEach), close resources.
28
+ */
29
+ export async function runDocument(engine: Engine, doc: Document): Promise<void> {
30
+ const flows = selectFlows(engine, doc.decls.filter(isFlowDecl));
31
+ const hooks = collectHooks(doc);
32
+ engine.emitter.emit({ kind: "run.started", data: { plan: planOf(flows) } });
33
+ const start = engine.clock.now();
34
+ await runSuite({ engine, doc, flows, hooks });
35
+ settleFlaky(engine);
36
+ const durationMs = engine.clock.now() - start;
37
+ engine.emitter.emit({ kind: "run.finished", data: { ...engine.result, durationMs } });
38
+ }
39
+
40
+ /**
41
+ * Suite hooks run once, on either side of every variant, so they get a root of
42
+ * their own: the document's bindings without any one variant's `matrix`.
43
+ *
44
+ * An `exit` in `setup` ends the run where it stands, so the variants never
45
+ * start, and `teardown` still runs, because what `setup` opened is still open.
46
+ * Both sides absorb their own, so the code reaches the host and `run.finished`
47
+ * is still emitted above.
48
+ */
49
+ async function runSuite(args: {
50
+ engine: Engine;
51
+ doc: Document;
52
+ flows: readonly FlowDecl[];
53
+ hooks: SuiteHooks;
54
+ }): Promise<void> {
55
+ const { engine, hooks } = args;
56
+ const suite = rootScope({ engine, doc: args.doc, variant: {} });
57
+ await absorbExit(engine, async () => {
58
+ await runHooks({ engine, hooks: hooks.setup, scope: suite });
59
+ await runVariants(args);
60
+ });
61
+ await absorbExit(engine, () => runHooks({ engine, hooks: hooks.teardown, scope: suite }));
62
+ }
63
+
64
+ /** Every `matrix` variant is a full pass over the flows, one after the other. */
65
+ async function runVariants(args: {
66
+ engine: Engine;
67
+ doc: Document;
68
+ flows: readonly FlowDecl[];
69
+ hooks: SuiteHooks;
70
+ }): Promise<void> {
71
+ for (const variant of matrixVariants(args.engine, args.doc)) {
72
+ await runVariant({ ...args, variant });
73
+ }
74
+ }
75
+
76
+ async function runVariant(args: {
77
+ engine: Engine;
78
+ doc: Document;
79
+ flows: readonly FlowDecl[];
80
+ hooks: SuiteHooks;
81
+ variant: Record<string, unknown>;
82
+ }): Promise<void> {
83
+ const root = rootScope({ engine: args.engine, doc: args.doc, variant: args.variant });
84
+ const teardowns = await runPrologue({ engine: args.engine, doc: args.doc, scope: root });
85
+ try {
86
+ await runFlows({ ...args, root });
87
+ } finally {
88
+ // Whatever ends the pass, a failure or an `exit` on its way out of the run,
89
+ // the suite still gives back what it opened.
90
+ await runTeardowns(teardowns);
91
+ }
92
+ }
93
+
94
+ async function runFlows(args: {
95
+ engine: Engine;
96
+ flows: readonly FlowDecl[];
97
+ hooks: SuiteHooks;
98
+ root: Scope;
99
+ }): Promise<void> {
100
+ for (const flow of args.flows) {
101
+ await runFlowWithHooks({ engine: args.engine, flow, hooks: args.hooks, root: args.root });
102
+ if (args.engine.bail && args.engine.result.failed > 0) break;
103
+ }
104
+ }
105
+
106
+ async function runFlowWithHooks(args: {
107
+ engine: Engine;
108
+ flow: FlowDecl;
109
+ hooks: SuiteHooks;
110
+ root: Scope;
111
+ }): Promise<void> {
112
+ // A scope of its own, so what `beforeEach` opens dies with the flow that
113
+ // needed it rather than lingering into the next one.
114
+ const scope = args.root.child();
115
+ const each = { engine: args.engine, scope };
116
+ await runHooks({ ...each, hooks: args.hooks.beforeEach });
117
+ await runFlow(args.engine, args.flow, scope);
118
+ await runHooks({ ...each, hooks: args.hooks.afterEach });
119
+ }
120
+
121
+ /**
122
+ * The scope a run reads from: the prelude, the plugin namespaces, `env`, the
123
+ * matrix variant and the document's own globals. One per variant, so what one
124
+ * variant binds is never visible to the next.
125
+ */
126
+ function rootScope(args: {
127
+ engine: Engine;
128
+ doc: Document;
129
+ variant: Record<string, unknown>;
130
+ }): Scope {
131
+ const base = (): Scope => createBaseScope({ engine: args.engine, variant: args.variant });
132
+ const root = base();
133
+ const graph = args.engine.imports;
134
+ // Before the document's own globals, so a local name of the same spelling
135
+ // wins. That is the rule fragments already follow.
136
+ if (graph) {
137
+ bindImports({ document: args.doc, uri: args.engine.uri, scope: root, graph, base });
138
+ }
139
+ bindGlobals(args.doc, root);
140
+ return root;
141
+ }
142
+
143
+ /** The cartesian product of the `matrix { … }` dimensions, else one empty variant. */
144
+ function matrixVariants(engine: Engine, doc: Document): Record<string, unknown>[] {
145
+ const matrix = doc.decls.find(isMatrixDecl);
146
+ if (!matrix) return [{}];
147
+ const scope = createScope();
148
+ scope.set("env", engine.env);
149
+ return product(evaluate(matrix.body, scope) as Record<string, unknown>);
150
+ }
151
+
152
+ function product(dims: Record<string, unknown>): Record<string, unknown>[] {
153
+ let combos: Record<string, unknown>[] = [{}];
154
+ for (const [key, values] of Object.entries(dims)) {
155
+ const list = Array.isArray(values) ? values : [values];
156
+ combos = combos.flatMap((combo) => list.map((value) => ({ ...combo, [key]: value })));
157
+ }
158
+ return combos;
159
+ }
160
+
161
+ /** `@only` focuses, `@skip` drops, then the runner's own `--flow` / `--tags` filters. */
162
+ function selectFlows(engine: Engine, flows: readonly FlowDecl[]): FlowDecl[] {
163
+ const only = flows.filter((flow) => hasAnnotation(flow, "only"));
164
+ const pool = only.length > 0 ? only : flows;
165
+ const kept = pool.filter((flow) => !hasAnnotation(flow, "skip"));
166
+ const named = kept.filter((flow) => matchesTitle(flow.title, engine.filter.flow));
167
+ return byTags(named, engine.filter.tags);
168
+ }
169
+
170
+ function byTags(flows: FlowDecl[], tags: readonly string[] | undefined): FlowDecl[] {
171
+ if (!tags || tags.length === 0) return flows;
172
+ return flows.filter((flow) => readTags(flow).some((tag) => tags.includes(tag)));
173
+ }
174
+
175
+ function planOf(flows: readonly FlowDecl[]): RunPlan {
176
+ return { flows: flows.map((flow) => ({ title: flow.title, steps: stepTitles(flow) })) };
177
+ }
178
+
179
+ function stepTitles(flow: FlowDecl): PlannedStep[] {
180
+ return flow.body.stmts.filter(isStepDecl).map((step) => ({ title: step.title }));
181
+ }
@@ -0,0 +1,48 @@
1
+ import { buildProblem, CODES, type ExpectStmt, evaluate, truthy } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import type { Engine } from "./engine.types.js";
4
+ import { nodeSource, nodeSpan } from "./node-span.js";
5
+ import { evalMatcher, type MatcherOutcome } from "./run-matcher.js";
6
+ import { settle } from "./settled.js";
7
+
8
+ /** Evaluate an `expect` (matcher clause, `.all` checks, negated, or subject) and emit the outcome. */
9
+ export async function runExpect(engine: Engine, stmt: ExpectStmt, scope: Scope): Promise<void> {
10
+ const outcome = stmt.matcher
11
+ ? await evalMatcher({ engine, stmt, scope })
12
+ : { passed: await evalBoolean({ stmt, scope }) };
13
+ record({ engine, stmt, outcome });
14
+ }
15
+
16
+ async function evalBoolean(args: { stmt: ExpectStmt; scope: Scope }): Promise<boolean> {
17
+ return args.stmt.modifier === "all" ? evalChecks(args) : evalSubject(args);
18
+ }
19
+
20
+ async function evalSubject(args: { stmt: ExpectStmt; scope: Scope }): Promise<boolean> {
21
+ if (!args.stmt.subject) return true;
22
+ const value = truthy(await settle(evaluate(args.stmt.subject, args.scope)));
23
+ return args.stmt.negate ? !value : value;
24
+ }
25
+
26
+ async function evalChecks(args: { stmt: ExpectStmt; scope: Scope }): Promise<boolean> {
27
+ for (const check of args.stmt.checks) {
28
+ if (!truthy(await settle(evaluate(check, args.scope)))) return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ function record(args: { engine: Engine; stmt: ExpectStmt; outcome: MatcherOutcome }): void {
34
+ if (args.outcome.passed) {
35
+ args.engine.result.passed += 1;
36
+ args.engine.emitter.emit({ kind: "expect.passed", data: { source: nodeSource(args.stmt) } });
37
+ return;
38
+ }
39
+ args.engine.result.failed += 1;
40
+ const problem = buildProblem({
41
+ spec: CODES.VN6001_ASSERTION_FAILED,
42
+ span: nodeSpan(args.stmt, args.engine.uri),
43
+ title: args.outcome.message ?? `Expectation failed: ${nodeSource(args.stmt)}`,
44
+ // The title is one line; the two sides it summarises travel as the body.
45
+ diff: args.outcome.diff,
46
+ });
47
+ args.engine.emitter.emit({ kind: "expect.failed", data: { problem } });
48
+ }