@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,69 @@
1
+ import { type FlowDecl, isLifecycleDecl, type LifecycleDecl, ProblemError } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import { hasAnnotation, readLock } from "./annotations.js";
4
+ import type { Engine } from "./engine.types.js";
5
+ import { recordFlaky } from "./flaky.js";
6
+ import { runAround } from "./run-around.js";
7
+ import { runWithAnnotations, withLock } from "./run-attempts.js";
8
+ import { runBlock } from "./run-block.js";
9
+ import { runOnHandlers } from "./run-lifecycle.js";
10
+ import { ExitSignal, isControlSignal } from "./signals.js";
11
+
12
+ /** Run a flow: body, then its `on failure`/`on success` handlers, then finish. */
13
+ export async function runFlow(engine: Engine, flow: FlowDecl, scope: Scope): Promise<void> {
14
+ engine.emitter.emit({ kind: "flow.started", data: { title: flow.title } });
15
+ const before = engine.result.failed;
16
+ const handlers = flow.body.stmts.filter(isOnHandler);
17
+ await runFlowBody(engine, flow, scope);
18
+ recordFlaky(engine, flow, before);
19
+ const status = engine.result.failed > before ? "failed" : "passed";
20
+ const event = status === "failed" ? "failure" : "success";
21
+ await runOnHandlers({ engine, handlers, event, scope });
22
+ engine.emitter.emit({ kind: "flow.finished", data: { title: flow.title, status } });
23
+ }
24
+
25
+ async function runFlowBody(engine: Engine, flow: FlowDecl, scope: Scope): Promise<void> {
26
+ try {
27
+ await withLock(engine, flowLock(flow), () =>
28
+ runWithAnnotations({
29
+ engine,
30
+ node: flow,
31
+ scope,
32
+ title: flow.title,
33
+ // The caller wants a promise; a block that never suspended returns none.
34
+ run: () => runAround(flow, () => runBlock(engine, flow.body, scope)),
35
+ }),
36
+ );
37
+ } catch (error) {
38
+ // `break`/`return` end the flow and no more than that. `exit` is not the
39
+ // flow's to absorb: it ends the run, so it keeps unwinding.
40
+ if (error instanceof ExitSignal) throw error;
41
+ if (!isControlSignal(error)) recordError(engine, error);
42
+ }
43
+ }
44
+
45
+ /** `@lock("x")` uses a named mutex; `@serial` serialises runs of the same flow. */
46
+ function flowLock(flow: FlowDecl): string | undefined {
47
+ return readLock(flow) ?? (hasAnnotation(flow, "serial") ? `serial:${flow.title}` : undefined);
48
+ }
49
+
50
+ function isOnHandler(stmt: unknown): stmt is LifecycleDecl {
51
+ return isLifecycleDecl(stmt) && Boolean((stmt as LifecycleDecl).event);
52
+ }
53
+
54
+ /**
55
+ * A failure the flow itself could not handle.
56
+ *
57
+ * One that already knows what it is (a Problem, with its code, its span and its
58
+ * help) travels on the event every reporter reads for failures. Flattening it to
59
+ * a log line would strip the code and the location the raiser worked out.
60
+ */
61
+ function recordError(engine: Engine, error: unknown): void {
62
+ engine.result.failed += 1;
63
+ if (error instanceof ProblemError) {
64
+ engine.emitter.emit({ kind: "expect.failed", data: { problem: error.problem } });
65
+ return;
66
+ }
67
+ const message = (error as { message?: string })?.message ?? String(error);
68
+ engine.emitter.emit({ kind: "log", data: { level: "error", message } });
69
+ }
@@ -0,0 +1,148 @@
1
+ import {
2
+ buildProblem,
3
+ CODES,
4
+ evaluate,
5
+ type ForEachStmt,
6
+ ProblemError,
7
+ typeName,
8
+ } from "@venn-lang/core";
9
+ import type { Scope } from "../scope/index.js";
10
+ import { planOf } from "./block-plan.js";
11
+ import { runPool } from "./concurrency.js";
12
+ import type { Engine } from "./engine.types.js";
13
+ import { nodeSpan } from "./node-span.js";
14
+ import { optsNumber } from "./opts.js";
15
+ import type { Pending } from "./pending.types.js";
16
+ import { runBlock, runSteps } from "./run-block.js";
17
+ import { settle } from "./settled.js";
18
+ import { BreakSignal, ContinueSignal } from "./signals.js";
19
+
20
+ /** `forEach x in list { concurrency: N } { … }`: iterate, up to N in flight. */
21
+ export async function runForEach(engine: Engine, stmt: ForEachStmt, scope: Scope): Promise<void> {
22
+ const source = await settle(evaluate(stmt.source, scope));
23
+ if (!Array.isArray(source)) throw notAList({ engine, stmt, source });
24
+ const concurrency = optsNumber(stmt.opts, "concurrency", scope) ?? 1;
25
+ // One at a time is the default and by far the common case: run it as a plain
26
+ // loop rather than through the pool, which allocates a worker, an array and a
27
+ // `Promise.all` to supervise a single sequential walk.
28
+ if (concurrency === 1) return sequential(engine, stmt, source, scope);
29
+ await runPool(source, concurrency, (item) => runIteration({ engine, stmt, item, scope }));
30
+ }
31
+
32
+ /**
33
+ * Anything but a list is refused, because iterating it zero times would report
34
+ * success: a test that checked nothing, dressed as one that passed. Built here,
35
+ * off the iteration path, so the loop itself pays nothing for it.
36
+ */
37
+ function notAList(args: { engine: Engine; stmt: ForEachStmt; source: unknown }): ProblemError {
38
+ return new ProblemError(
39
+ buildProblem({
40
+ spec: CODES.VN3015_NOT_A_LIST,
41
+ span: nodeSpan(args.stmt.source, args.engine.uri),
42
+ title: `forEach needs a list, and this is a ${typeName(args.source)}.`,
43
+ help: helpFor(args.source),
44
+ }),
45
+ );
46
+ }
47
+
48
+ /** The everyday cause: an endpoint answering `{ data: [...] }` rather than a list. */
49
+ function helpFor(source: unknown): string | undefined {
50
+ if (typeName(source) !== "map") return undefined;
51
+ return "Name the list inside it, as in `forEach item in res.data`.";
52
+ }
53
+
54
+ /**
55
+ * Walk the items one at a time, staying synchronous for as long as the body
56
+ * does. A body of pure work (a binding, a comparison, arithmetic) never
57
+ * suspends, so 50k iterations cost no promises at all. The first iteration that
58
+ * does suspend hands the remainder to {@link resume}, from where it left off.
59
+ */
60
+ function sequential(
61
+ engine: Engine,
62
+ stmt: ForEachStmt,
63
+ items: readonly unknown[],
64
+ scope: Scope,
65
+ ): Pending {
66
+ const plan = planOf(stmt.body);
67
+ const name = stmt.item;
68
+ if (plan.defers) return slowSequential(engine, stmt, items, scope);
69
+ const child = scope.child();
70
+ for (let at = 0; at < items.length; at += 1) {
71
+ try {
72
+ child.set(name, items[at]);
73
+ const pending = runSteps(engine, plan.steps, child);
74
+ if (pending) return resume(engine, stmt, items, at + 1, scope, pending);
75
+ } catch (error) {
76
+ if (error instanceof BreakSignal) return undefined;
77
+ if (error instanceof ContinueSignal) continue;
78
+ throw error;
79
+ }
80
+ }
81
+ return undefined;
82
+ }
83
+
84
+ function slowSequential(
85
+ engine: Engine,
86
+ stmt: ForEachStmt,
87
+ items: readonly unknown[],
88
+ scope: Scope,
89
+ ): Pending {
90
+ for (let at = 0; at < items.length; at += 1) {
91
+ try {
92
+ const pending = iterate(engine, stmt, items[at], scope);
93
+ if (pending) return resume(engine, stmt, items, at + 1, scope, pending);
94
+ } catch (error) {
95
+ if (error instanceof BreakSignal) return undefined;
96
+ if (error instanceof ContinueSignal) continue;
97
+ throw error;
98
+ }
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function iterate(engine: Engine, stmt: ForEachStmt, item: unknown, scope: Scope): Pending {
104
+ const child = scope.child();
105
+ child.set(stmt.item, item);
106
+ return runBlock(engine, stmt.body, child);
107
+ }
108
+
109
+ async function resume(
110
+ engine: Engine,
111
+ stmt: ForEachStmt,
112
+ items: readonly unknown[],
113
+ from: number,
114
+ scope: Scope,
115
+ pending: Promise<void>,
116
+ ): Promise<void> {
117
+ try {
118
+ await pending;
119
+ } catch (error) {
120
+ if (error instanceof BreakSignal) return;
121
+ if (!(error instanceof ContinueSignal)) throw error;
122
+ }
123
+ for (let at = from; at < items.length; at += 1) {
124
+ try {
125
+ await iterate(engine, stmt, items[at], scope);
126
+ } catch (error) {
127
+ if (error instanceof BreakSignal) return;
128
+ if (error instanceof ContinueSignal) continue;
129
+ throw error;
130
+ }
131
+ }
132
+ }
133
+
134
+ async function runIteration(args: {
135
+ engine: Engine;
136
+ stmt: ForEachStmt;
137
+ item: unknown;
138
+ scope: Scope;
139
+ }): Promise<void> {
140
+ const child = args.scope.child();
141
+ child.set(args.stmt.item, args.item);
142
+ try {
143
+ await runBlock(args.engine, args.stmt.body, child);
144
+ } catch (error) {
145
+ if (error instanceof BreakSignal || error instanceof ContinueSignal) return;
146
+ throw error;
147
+ }
148
+ }
@@ -0,0 +1,9 @@
1
+ import type { GroupDecl } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import type { Engine } from "./engine.types.js";
4
+ import { runBlock } from "./run-block.js";
5
+
6
+ /** A group only clusters steps in the graph; it shares the enclosing scope. */
7
+ export async function runGroup(engine: Engine, stmt: GroupDecl, scope: Scope): Promise<void> {
8
+ await runBlock(engine, stmt.body, scope);
9
+ }
@@ -0,0 +1,21 @@
1
+ import { evaluate, type IfStmt, isIfStmt, truthy } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import type { Engine } from "./engine.types.js";
4
+ import type { Pending } from "./pending.types.js";
5
+ import { runBlock } from "./run-block.js";
6
+ import { isPending } from "./settled.js";
7
+
8
+ /** `if cond { … } else if … else { … }`: evaluate and run the taken branch. */
9
+ export function runIf(engine: Engine, stmt: IfStmt, scope: Scope): Pending {
10
+ const cond = evaluate(stmt.cond, scope);
11
+ if (isPending(cond)) return cond.then((value) => void taken(engine, stmt, scope, truthy(value)));
12
+ return taken(engine, stmt, scope, truthy(cond));
13
+ }
14
+
15
+ function taken(engine: Engine, stmt: IfStmt, scope: Scope, yes: boolean): Pending {
16
+ if (yes) return runBlock(engine, stmt.then, scope.child());
17
+ const branch = stmt.otherwise;
18
+ if (!branch) return undefined;
19
+ if (isIfStmt(branch)) return runIf(engine, branch, scope);
20
+ return runBlock(engine, branch, scope.child());
21
+ }
@@ -0,0 +1,86 @@
1
+ import { buildProblem, CODES, type LifecycleDecl, type Problem } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import type { Engine } from "./engine.types.js";
4
+ import { nodeSpan } from "./node-span.js";
5
+ import { runBlock } from "./run-block.js";
6
+ import { ExitSignal, isControlSignal } from "./signals.js";
7
+
8
+ /**
9
+ * Run a set of lifecycle blocks (setup/teardown/beforeEach/afterEach) in order.
10
+ *
11
+ * The caller passes the scope the hooks belong to (the suite root for
12
+ * setup/teardown, the flow's own scope for beforeEach/afterEach) and each body
13
+ * runs in a child of it, the way the `on` handlers below do. In a parentless
14
+ * scope `env`, the prelude, the top-level bindings and the open resources would
15
+ * all read as undefined and the hook would run anyway: a teardown written as
16
+ * `db.exec "DELETE FROM orders WHERE run = '${env.RUN_ID}'"` deletes the table
17
+ * it was meant to tidy.
18
+ */
19
+ export async function runHooks(args: {
20
+ engine: Engine;
21
+ hooks: readonly LifecycleDecl[];
22
+ scope: Scope;
23
+ }): Promise<void> {
24
+ for (const hook of args.hooks) await runHook(args.engine, hook, args.scope);
25
+ }
26
+
27
+ /**
28
+ * Run the `on <event>` handlers whose event matches (e.g. "failure"/"success").
29
+ *
30
+ * Through the same boundary as the hooks above: a handler that reacts to a
31
+ * failure by failing itself is a second failure, not the end of the run.
32
+ */
33
+ export async function runOnHandlers(args: {
34
+ engine: Engine;
35
+ handlers: readonly LifecycleDecl[];
36
+ event: string;
37
+ scope: Scope;
38
+ }): Promise<void> {
39
+ for (const handler of args.handlers) {
40
+ if (handler.event === args.event) await runHook(args.engine, handler, args.scope);
41
+ }
42
+ }
43
+
44
+ /**
45
+ * A hook that throws fails the suite instead of ending the run: it is counted
46
+ * and reported, and the walk carries on to `run.finished`, so the reporters
47
+ * still close their files and the files after this one still run.
48
+ */
49
+ async function runHook(engine: Engine, hook: LifecycleDecl, scope: Scope): Promise<void> {
50
+ try {
51
+ await runBlock(engine, hook.body, scope.child());
52
+ } catch (error) {
53
+ // Read as a flow body reads it: `break`/`return` unwind, they do not fail.
54
+ // `exit` is not the hook's to absorb: it ends the run, and the code it
55
+ // carries is the whole verdict, so it keeps unwinding to whoever ends it.
56
+ if (error instanceof ExitSignal) throw error;
57
+ if (!isControlSignal(error)) recordFailure({ engine, hook, error });
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Counted where a failing flow is counted, and carried on the event that already
63
+ * puts a Problem in front of every reporter. A hook failure the reporters never
64
+ * hear about ends as a green artifact over a broken run.
65
+ */
66
+ function recordFailure(args: { engine: Engine; hook: LifecycleDecl; error: unknown }): void {
67
+ args.engine.result.failed += 1;
68
+ args.engine.emitter.emit({ kind: "expect.failed", data: { problem: hookProblem(args) } });
69
+ }
70
+
71
+ function hookProblem(args: { engine: Engine; hook: LifecycleDecl; error: unknown }): Problem {
72
+ return buildProblem({
73
+ spec: CODES.VN7004_HOOK_FAILED,
74
+ span: nodeSpan(args.hook, args.engine.uri),
75
+ title: `${hookLabel(args.hook)} failed: ${messageOf(args.error)}`,
76
+ });
77
+ }
78
+
79
+ /** The block named the way the user wrote it: `setup`, or `on failure`. */
80
+ function hookLabel(hook: LifecycleDecl): string {
81
+ return hook.hook ?? (hook.event ? `on ${hook.event}` : "lifecycle");
82
+ }
83
+
84
+ function messageOf(error: unknown): string {
85
+ return (error as { message?: string })?.message ?? String(error);
86
+ }
@@ -0,0 +1,99 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import {
3
+ buildDiff,
4
+ type Diff,
5
+ type ExpectStmt,
6
+ evaluate,
7
+ type MatcherClause,
8
+ } from "@venn-lang/core";
9
+ import type { MatcherArgs, MatcherDefinition } from "@venn-lang/sdk";
10
+ import type { ResolvedMatcher } from "../registry/index.js";
11
+ import type { Scope } from "../scope/index.js";
12
+ import { callParams } from "./call-params.js";
13
+ import type { Engine } from "./engine.types.js";
14
+ import { nodeSource } from "./node-span.js";
15
+
16
+ /** The pass/fail, the one-line message, and the diff that message summarises. */
17
+ export interface MatcherOutcome {
18
+ passed: boolean;
19
+ message?: string;
20
+ diff?: Diff;
21
+ }
22
+
23
+ /** Resolve a bareword matcher from the registry and run it against the subject. */
24
+ export async function evalMatcher(args: {
25
+ engine: Engine;
26
+ stmt: ExpectStmt;
27
+ scope: Scope;
28
+ }): Promise<MatcherOutcome> {
29
+ const clause = args.stmt.matcher as MatcherClause;
30
+ const resolved = args.engine.registry.matcher(clause.name);
31
+ if (!resolved) throw unknownMatcher(clause.name);
32
+ const input = buildArgs({ ...args, resolved, clause });
33
+ const raw = await resolved.matcher.test(input);
34
+ if (args.stmt.negate ? !raw : raw) return { passed: true };
35
+ return failure({ matcher: resolved.matcher, input, stmt: args.stmt });
36
+ }
37
+
38
+ /**
39
+ * A failing matcher hands back the two sides it compared, labelled with how the
40
+ * flow spelled the subject. A negated expect gets no diff on purpose: under
41
+ * `not` the two sides matched, and "expected 200, actual 200" explains nothing.
42
+ */
43
+ function failure(args: {
44
+ matcher: MatcherDefinition;
45
+ input: MatcherArgs<unknown>;
46
+ stmt: ExpectStmt;
47
+ }): MatcherOutcome {
48
+ const message = args.matcher.message(args.input);
49
+ const detail = args.stmt.negate ? undefined : args.matcher.detail?.(args.input);
50
+ if (!detail) return { passed: false, message };
51
+ const label = subjectLabel(args.stmt);
52
+ return { passed: false, message, diff: buildDiff({ label, ...detail }) };
53
+ }
54
+
55
+ /** The diff's header: the subject as the flow spelled it, on one line. */
56
+ function subjectLabel(stmt: ExpectStmt): string {
57
+ const source = stmt.subject ? nodeSource(stmt.subject).replace(/\s+/g, " ").trim() : "";
58
+ return source === "" ? "value" : source;
59
+ }
60
+
61
+ function buildArgs(args: {
62
+ resolved: ResolvedMatcher;
63
+ stmt: ExpectStmt;
64
+ clause: MatcherClause;
65
+ scope: Scope;
66
+ engine: Engine;
67
+ }): MatcherArgs<unknown> {
68
+ const { stmt, clause, scope } = args;
69
+ const subject = stmt.subject ? evaluate(stmt.subject, scope) : undefined;
70
+ const positional = clause.args.map((expr) => evaluate(expr, scope));
71
+ return { subject, args: positional, params: matcherOptions(args) };
72
+ }
73
+
74
+ /**
75
+ * A matcher's options map goes through the same gate an action's does: a key it
76
+ * never declared is refused rather than dropped, and a value it rejects reads as
77
+ * one line. Nothing checks these before the run, since `checkOptions` only ever
78
+ * sees calls, so this is the only place `{ withinn: 1 }` is ever noticed.
79
+ */
80
+ function matcherOptions(args: {
81
+ resolved: ResolvedMatcher;
82
+ stmt: ExpectStmt;
83
+ clause: MatcherClause;
84
+ scope: Scope;
85
+ engine: Engine;
86
+ }): unknown {
87
+ const { resolved, stmt, clause, scope, engine } = args;
88
+ const raw = clause.opts ? evaluate(clause.opts, scope) : {};
89
+ const schema = resolved.matcher.params;
90
+ return callParams({ schema, opts: clause.opts, raw, site: stmt, uri: engine.uri });
91
+ }
92
+
93
+ function unknownMatcher(name: string): VennError {
94
+ return new VennError({
95
+ code: "VN2004",
96
+ message: `Unknown matcher "${name}".`,
97
+ detail: { name },
98
+ });
99
+ }
@@ -0,0 +1,68 @@
1
+ import type { ParallelStmt, Statement } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import { branchEngine } from "./branch-engine.js";
4
+ import { runPool } from "./concurrency.js";
5
+ import type { Engine } from "./engine.types.js";
6
+ import { optsNumber } from "./opts.js";
7
+ import { optsText } from "./opts-text.js";
8
+ import { runStatement } from "./run-statements.js";
9
+ import { CancelSignal, isControlSignal } from "./signals.js";
10
+
11
+ /**
12
+ * `parallel { concurrency: 2, onError: "collect" } { … }`: run each child
13
+ * statement concurrently.
14
+ *
15
+ * The block does not outlive itself. However it ends, every branch has either
16
+ * finished or been cancelled by the time this returns: `Promise.all` on its own
17
+ * rejects at the first failure and leaves the rest running, which is how a
18
+ * finished test carries on making requests.
19
+ */
20
+ export async function runParallel(engine: Engine, stmt: ParallelStmt, scope: Scope): Promise<void> {
21
+ const limit = optsNumber(stmt.opts, "concurrency", scope) ?? stmt.body.stmts.length;
22
+ const onError = optsText(stmt.opts, "onError", scope) ?? "cancel";
23
+ const controller = new AbortController();
24
+ const failures: unknown[] = [];
25
+ await runPool(stmt.body.stmts, Math.max(1, limit), (child) =>
26
+ branch({ engine, child, scope, controller, failures, onError }),
27
+ );
28
+ report(failures);
29
+ }
30
+
31
+ interface BranchArgs {
32
+ engine: Engine;
33
+ child: Statement;
34
+ scope: Scope;
35
+ controller: AbortController;
36
+ failures: unknown[];
37
+ onError: string;
38
+ }
39
+
40
+ /**
41
+ * One branch, and what its failure means for the others.
42
+ *
43
+ * `cancel`, the default, stops the siblings at their next statement boundary,
44
+ * because a run that has already failed should stop working. `collect` lets them
45
+ * all finish and reports every failure, which is what a set of independent
46
+ * checks wants.
47
+ */
48
+ async function branch(args: BranchArgs): Promise<void> {
49
+ const engine = branchEngine(args.engine, args.controller.signal);
50
+ try {
51
+ await runStatement(engine, args.child, args.scope.child());
52
+ } catch (error) {
53
+ if (error instanceof CancelSignal) return;
54
+ if (isControlSignal(error)) throw error;
55
+ args.failures.push(error);
56
+ if (args.onError === "cancel") args.controller.abort();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * One failure is raised as itself, so the reader sees the message the branch
62
+ * produced. Several are raised together rather than one being picked.
63
+ */
64
+ function report(failures: readonly unknown[]): void {
65
+ if (failures.length === 0) return;
66
+ if (failures.length === 1) throw failures[0];
67
+ throw new AggregateError(failures, `${failures.length} parallel branches failed.`);
68
+ }
@@ -0,0 +1,59 @@
1
+ import {
2
+ type Block,
3
+ type Declaration,
4
+ type Document,
5
+ isLifecycleDecl,
6
+ isRunnable,
7
+ type LifecycleDecl,
8
+ } from "@venn-lang/core";
9
+ import type { Scope } from "../scope/index.js";
10
+ import type { Engine } from "./engine.types.js";
11
+ import { runBlock } from "./run-block.js";
12
+ import { runStatement } from "./run-statements.js";
13
+
14
+ /** A cleanup a program deferred, to be run on the way out. */
15
+ export type Teardown = () => Promise<void>;
16
+
17
+ /**
18
+ * A document's top-level statements, run once, in the order they are written.
19
+ *
20
+ * The same lines mean the same thing in both modes: a script *is* its prologue,
21
+ * and a test file runs one before its flows. So a `const` calling a verb at the
22
+ * top of a file opens the thing either way, and the flows never find `null`.
23
+ *
24
+ * @param args.into Where deferred cleanups accumulate, in written order. A
25
+ * program's ending is registered before its statements run, so it has to see
26
+ * what they defer as they reach it rather than afterwards.
27
+ * @returns The teardowns collected, which is `into` when one was supplied.
28
+ */
29
+ export async function runPrologue(args: {
30
+ engine: Engine;
31
+ doc: Document;
32
+ scope: Scope;
33
+ into?: Teardown[];
34
+ }): Promise<Teardown[]> {
35
+ const teardowns = args.into ?? [];
36
+ for (const node of args.doc.decls) {
37
+ if (isDefer(node)) teardowns.push(deferred({ ...args, body: node.body }));
38
+ else if (isRunnable(node)) await runStatement(args.engine, node, args.scope);
39
+ }
40
+ return teardowns;
41
+ }
42
+
43
+ /** `defer { … }` written at the top of a file: registered here, run on the way out. */
44
+ function isDefer(node: Declaration): node is Declaration & LifecycleDecl {
45
+ return isLifecycleDecl(node) && node.hook === "defer";
46
+ }
47
+
48
+ function deferred(args: { engine: Engine; body: Block; scope: Scope }): Teardown {
49
+ // Cleanup must complete even when what it tidies was cancelled mid-flight.
50
+ const engine: Engine = { ...args.engine, signal: undefined };
51
+ return async () => {
52
+ await runBlock(engine, args.body, args.scope.child());
53
+ };
54
+ }
55
+
56
+ /** Undo in reverse: what opened last is given back first. */
57
+ export async function runTeardowns(teardowns: readonly Teardown[]): Promise<void> {
58
+ for (const teardown of [...teardowns].reverse()) await teardown();
59
+ }
@@ -0,0 +1,20 @@
1
+ import type { RaceStmt } from "@venn-lang/core";
2
+ import type { Scope } from "../scope/index.js";
3
+ import { branchEngine } from "./branch-engine.js";
4
+ import type { Engine } from "./engine.types.js";
5
+ import { runStatement } from "./run-statements.js";
6
+
7
+ /** `race { … }`: the first branch to settle wins, the losers are cancelled. */
8
+ export async function runRace(engine: Engine, stmt: RaceStmt, scope: Scope): Promise<void> {
9
+ const controller = new AbortController();
10
+ const branches = stmt.body.stmts.map((child) =>
11
+ Promise.resolve(runStatement(branchEngine(engine, controller.signal), child, scope.child())),
12
+ );
13
+ try {
14
+ await Promise.race(branches);
15
+ } finally {
16
+ controller.abort();
17
+ // The losers reject with CancelSignal once they reach a statement boundary.
18
+ for (const branch of branches) branch.catch(() => {});
19
+ }
20
+ }
@@ -0,0 +1,54 @@
1
+ import {
2
+ buildProblem,
3
+ CODES,
4
+ evaluate,
5
+ ProblemError,
6
+ type RepeatStmt,
7
+ typeName,
8
+ } from "@venn-lang/core";
9
+ import type { Scope } from "../scope/index.js";
10
+ import type { Engine } from "./engine.types.js";
11
+ import { nodeSpan } from "./node-span.js";
12
+ import { runBlock } from "./run-block.js";
13
+ import { BreakSignal, ContinueSignal } from "./signals.js";
14
+
15
+ /** `repeat N as i { … }`: run the body N times, honouring break/continue. */
16
+ export async function runRepeat(engine: Engine, stmt: RepeatStmt, scope: Scope): Promise<void> {
17
+ const count = toCount(engine, stmt, evaluate(stmt.count, scope));
18
+ for (let i = 1; i <= count; i++) {
19
+ const child = scope.child();
20
+ if (stmt.index) child.set(stmt.index, i);
21
+ try {
22
+ const pending = runBlock(engine, stmt.body, child);
23
+ if (pending) await pending;
24
+ } catch (error) {
25
+ if (error instanceof BreakSignal) break;
26
+ if (error instanceof ContinueSignal) continue;
27
+ throw error;
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * A count the machine cannot read as one is refused, because running the body
34
+ * zero times would report success: `repeat cfg.times` with nothing behind
35
+ * `times` would check nothing and pass.
36
+ *
37
+ * Zero and below still mean "not at all": a bound that computes to none is a
38
+ * program saying so, not a mistake.
39
+ */
40
+ function toCount(engine: Engine, stmt: RepeatStmt, value: unknown): number {
41
+ if (typeof value !== "number" || !Number.isFinite(value)) throw notANumber(engine, stmt, value);
42
+ return value > 0 ? Math.floor(value) : 0;
43
+ }
44
+
45
+ function notANumber(engine: Engine, stmt: RepeatStmt, value: unknown): ProblemError {
46
+ return new ProblemError(
47
+ buildProblem({
48
+ spec: CODES.VN3016_NOT_A_NUMBER,
49
+ span: nodeSpan(stmt.count, engine.uri),
50
+ title: `repeat needs a number of times, and this is a ${typeName(value)}.`,
51
+ help: "Give it a count, as in `repeat 3 { … }`.",
52
+ }),
53
+ );
54
+ }
@@ -0,0 +1,41 @@
1
+ import { VennError } from "@venn-lang/contracts";
2
+ import { evaluate, type FragmentDecl, type ParamList, type RunStmt } from "@venn-lang/core";
3
+ import { createScope, type Scope } from "../scope/index.js";
4
+ import type { Engine } from "./engine.types.js";
5
+ import { runBlock } from "./run-block.js";
6
+ import { ReturnSignal } from "./signals.js";
7
+
8
+ /** `run fragment(args) as x`: invoke a fragment and bind its return value. */
9
+ export async function runRun(engine: Engine, stmt: RunStmt, scope: Scope): Promise<void> {
10
+ const fragment = engine.fragments.get(stmt.target);
11
+ if (!fragment) throw unknownFragment(stmt.target);
12
+ const args = (stmt.args?.args ?? []).map((arg) => evaluate(arg.value, scope));
13
+ const fragScope = createScope();
14
+ bindParams(fragScope, fragment.params, args);
15
+ const value = await runFragment(engine, fragment, fragScope);
16
+ if (stmt.bind) scope.set(stmt.bind, value);
17
+ }
18
+
19
+ async function runFragment(engine: Engine, fragment: FragmentDecl, scope: Scope): Promise<unknown> {
20
+ try {
21
+ await runBlock(engine, fragment.body, scope);
22
+ return undefined;
23
+ } catch (error) {
24
+ if (error instanceof ReturnSignal) return error.value;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ function bindParams(scope: Scope, params: ParamList | undefined, args: readonly unknown[]): void {
30
+ (params?.params ?? []).forEach((param, index) => {
31
+ scope.set(param.name, args[index]);
32
+ });
33
+ }
34
+
35
+ function unknownFragment(name: string): VennError {
36
+ return new VennError({
37
+ code: "VN2005",
38
+ message: `Unknown fragment "${name}".`,
39
+ detail: { fragment: name },
40
+ });
41
+ }