@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
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Block,
|
|
3
|
+
compileExpr,
|
|
4
|
+
isLetStmt,
|
|
5
|
+
isLifecycleDecl,
|
|
6
|
+
type LetStmt,
|
|
7
|
+
type Statement,
|
|
8
|
+
} from "@venn-lang/core";
|
|
9
|
+
import type { Scope } from "../scope/index.js";
|
|
10
|
+
import type { Engine } from "./engine.types.js";
|
|
11
|
+
import { actionCall } from "./invocation.js";
|
|
12
|
+
import type { Pending } from "./pending.types.js";
|
|
13
|
+
import { runStatement } from "./run-statements.js";
|
|
14
|
+
import { isPending } from "./settled.js";
|
|
15
|
+
|
|
16
|
+
/** One statement of a block, bound to everything the source already settled. */
|
|
17
|
+
export type Step = (engine: Engine, scope: Scope) => Pending;
|
|
18
|
+
|
|
19
|
+
/** A block reduced to what running it needs, decided once from the source. */
|
|
20
|
+
export interface BlockPlan {
|
|
21
|
+
readonly steps: readonly Step[];
|
|
22
|
+
readonly defers: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const plans = new WeakMap<Block, BlockPlan>();
|
|
26
|
+
|
|
27
|
+
/** The plan for a block, built on first use and cached against the node itself. */
|
|
28
|
+
export function planOf(block: Block): BlockPlan {
|
|
29
|
+
const known = plans.get(block);
|
|
30
|
+
if (known) return known;
|
|
31
|
+
const built: BlockPlan = {
|
|
32
|
+
steps: block.stmts.map(stepOf),
|
|
33
|
+
defers: block.stmts.some(isDefer),
|
|
34
|
+
};
|
|
35
|
+
plans.set(block, built);
|
|
36
|
+
return built;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isDefer(stmt: Statement): boolean {
|
|
40
|
+
return isLifecycleDecl(stmt) && stmt.hook === "defer";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function stepOf(stmt: Statement): Step {
|
|
44
|
+
return plainLet(stmt) ?? ((engine, scope) => runStatement(engine, stmt, scope));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `const y = x * 2`: no trailing args, no options map, and a value that is not a
|
|
49
|
+
* dotted path, so no registry lookup can turn it into a verb. The name, the
|
|
50
|
+
* compiled expression and the decision are all fixed by the source.
|
|
51
|
+
*/
|
|
52
|
+
function plainLet(stmt: Statement): Step | undefined {
|
|
53
|
+
if (!isLetStmt(stmt)) return undefined;
|
|
54
|
+
const let_ = stmt as LetStmt;
|
|
55
|
+
if (let_.args.length > 0 || let_.opts) return undefined;
|
|
56
|
+
const call = actionCall(let_.value);
|
|
57
|
+
if (call && call.target.indexOf(".") >= 0) return undefined;
|
|
58
|
+
const name = let_.name;
|
|
59
|
+
const thunk = compileExpr(let_.value);
|
|
60
|
+
return (_engine, scope) => bind(name, thunk(scope), scope);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function bind(name: string, value: unknown, scope: Scope): Pending {
|
|
64
|
+
if (isPending(value)) return value.then((settled) => scope.set(name, settled));
|
|
65
|
+
return void scope.set(name, value);
|
|
66
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Engine } from "./engine.types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A per-branch view of the engine carrying the cancellation signal.
|
|
5
|
+
*
|
|
6
|
+
* Into `ctx` as well as onto the engine: the statement walker checks the signal
|
|
7
|
+
* between statements, but a branch parked inside a long action is only reachable
|
|
8
|
+
* through the context the action was handed. Without it, cancelling a branch
|
|
9
|
+
* waits for whatever it is already doing to finish on its own.
|
|
10
|
+
*/
|
|
11
|
+
export function branchEngine(engine: Engine, signal: AbortSignal): Engine {
|
|
12
|
+
return { ...engine, signal, ctx: { ...engine.ctx, signal } };
|
|
13
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
display,
|
|
6
|
+
type MapEntry,
|
|
7
|
+
type MapLit,
|
|
8
|
+
type Problem,
|
|
9
|
+
ProblemError,
|
|
10
|
+
typeName,
|
|
11
|
+
} from "@venn-lang/core";
|
|
12
|
+
import type { ZodType } from "@venn-lang/sdk";
|
|
13
|
+
import { nodeSpan } from "./node-span.js";
|
|
14
|
+
import { unknownOptions } from "./unknown-option.js";
|
|
15
|
+
|
|
16
|
+
/** The bits of a Zod failure this reads. Structural, so no Zod internals leak out. */
|
|
17
|
+
interface Issue {
|
|
18
|
+
code?: string;
|
|
19
|
+
path?: readonly PropertyKey[];
|
|
20
|
+
expected?: string;
|
|
21
|
+
values?: readonly unknown[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Zod's word for a type, in the word the language uses for it. */
|
|
25
|
+
const WORDS: Record<string, string> = {
|
|
26
|
+
object: "map",
|
|
27
|
+
record: "map",
|
|
28
|
+
array: "list",
|
|
29
|
+
boolean: "bool",
|
|
30
|
+
int: "number",
|
|
31
|
+
bigint: "number",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The options map of a call, an action's or a matcher's, validated before the
|
|
36
|
+
* thing it belongs to ever sees it.
|
|
37
|
+
*
|
|
38
|
+
* Unknown keys are refused rather than stripped, because `z.object` drops them
|
|
39
|
+
* in silence and `crypto.hash "x" { algorithmm: "sha512" }` would then hash with
|
|
40
|
+
* the default. A rejected value becomes a `Problem` rather than escaping as a
|
|
41
|
+
* ZodError, whose `message` is a JSON dump of issues.
|
|
42
|
+
*
|
|
43
|
+
* @param args.site Where the call is written, for a failure with no smaller node
|
|
44
|
+
* to point at: a required option nobody typed leaves nothing to underline.
|
|
45
|
+
* @returns The parsed options.
|
|
46
|
+
* @throws ProblemError `VN3010` when a key is unknown or a value is rejected.
|
|
47
|
+
*/
|
|
48
|
+
export function callParams(args: {
|
|
49
|
+
schema: ZodType | undefined;
|
|
50
|
+
opts: MapLit | undefined;
|
|
51
|
+
raw: unknown;
|
|
52
|
+
site: AstNode;
|
|
53
|
+
uri: string;
|
|
54
|
+
}): unknown {
|
|
55
|
+
const { schema, opts, raw, site, uri } = args;
|
|
56
|
+
if (!schema) return raw;
|
|
57
|
+
const unknown = unknownOptions({ opts, params: schema, uri });
|
|
58
|
+
if (unknown[0]) throw new ProblemError(unknown[0]);
|
|
59
|
+
return parsed({ schema, opts, raw, site, uri });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Parse, and turn a rejection into a sentence about the option that failed. */
|
|
63
|
+
function parsed(args: {
|
|
64
|
+
schema: ZodType;
|
|
65
|
+
opts: MapLit | undefined;
|
|
66
|
+
raw: unknown;
|
|
67
|
+
site: AstNode;
|
|
68
|
+
uri: string;
|
|
69
|
+
}): unknown {
|
|
70
|
+
try {
|
|
71
|
+
return args.schema.parse(args.raw);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
const issue = firstIssue(error);
|
|
74
|
+
// Not a schema rejection: a transform threw, and it already says why.
|
|
75
|
+
if (!issue) throw error;
|
|
76
|
+
const { raw, opts, site, uri } = args;
|
|
77
|
+
throw new ProblemError(badOption({ issue, raw, opts, site, uri }));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function badOption(args: {
|
|
82
|
+
issue: Issue;
|
|
83
|
+
raw: unknown;
|
|
84
|
+
opts: MapLit | undefined;
|
|
85
|
+
site: AstNode;
|
|
86
|
+
uri: string;
|
|
87
|
+
}): Problem {
|
|
88
|
+
const path = args.issue.path ?? [];
|
|
89
|
+
return buildProblem({
|
|
90
|
+
spec: CODES.VN3010_TYPE_MISMATCH,
|
|
91
|
+
// The entry that failed, else the map; a required option nobody wrote has
|
|
92
|
+
// neither, and then the call itself is the nearest true place.
|
|
93
|
+
span: nodeSpan(entryFor(args.opts, path) ?? args.opts ?? args.site, args.uri),
|
|
94
|
+
title: title({ key: path.join("."), issue: args.issue, value: valueAt(args.raw, path) }),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One line, naming the option and what it wanted. Only the shapes worth
|
|
100
|
+
* modelling get their own sentence; anything else says so plainly rather than
|
|
101
|
+
* dressing up an issue it does not understand.
|
|
102
|
+
*/
|
|
103
|
+
function title(args: { key: string; issue: Issue; value: unknown }): string {
|
|
104
|
+
const { key, issue, value } = args;
|
|
105
|
+
if (!key) return "These options are not valid here.";
|
|
106
|
+
if (issue.code === "invalid_value" && issue.values) return oneOf(key, issue.values, value);
|
|
107
|
+
if (issue.code !== "invalid_type" || !issue.expected) return `"${key}" is not a valid option.`;
|
|
108
|
+
const needs = WORDS[issue.expected] ?? issue.expected;
|
|
109
|
+
if (value === undefined) return `"${key}" is required here, and it takes a ${needs}.`;
|
|
110
|
+
return `"${key}" needs a ${needs}, and ${shown(value)} is a ${typeName(value)}.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `{ algorithm: "sha5" }` against a schema that lists the digests it knows. */
|
|
114
|
+
function oneOf(key: string, values: readonly unknown[], value: unknown): string {
|
|
115
|
+
const accepted = values.map((option) => shown(option)).join(", ");
|
|
116
|
+
return `"${key}" must be one of ${accepted} — not ${shown(value)}.`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A value as a message shows it: strings quoted, so `"soon"` reads as text. */
|
|
120
|
+
function shown(value: unknown): string {
|
|
121
|
+
return typeof value === "string" ? JSON.stringify(value) : display(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function valueAt(raw: unknown, path: readonly PropertyKey[]): unknown {
|
|
125
|
+
let value = raw;
|
|
126
|
+
for (const key of path) value = (value as Record<PropertyKey, unknown> | undefined)?.[key];
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The written entry to underline, when the failure names a key this map spells. */
|
|
131
|
+
function entryFor(opts: MapLit | undefined, path: readonly PropertyKey[]): MapEntry | undefined {
|
|
132
|
+
const key = String(path[0] ?? "");
|
|
133
|
+
return opts?.entries.find((entry) => entry.key === key);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Zod's failure, read structurally: the runtime does not depend on zod itself. */
|
|
137
|
+
function firstIssue(error: unknown): Issue | undefined {
|
|
138
|
+
const issues = (error as { issues?: unknown } | undefined)?.issues;
|
|
139
|
+
return Array.isArray(issues) ? (issues[0] as Issue) : undefined;
|
|
140
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Something a run opened, and how to give it back. */
|
|
2
|
+
export type Cleanup = () => Promise<void>;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where a run registers what it opened, for whoever owns the process.
|
|
6
|
+
*
|
|
7
|
+
* The runtime does not decide when a program ends: a served program ends when
|
|
8
|
+
* the machine says so, and only the host hears that. So the run says what has to
|
|
9
|
+
* happen on the way out, and the host says when.
|
|
10
|
+
*/
|
|
11
|
+
export interface CleanupSink {
|
|
12
|
+
add(cleanup: Cleanup): unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A sink that also runs what it collected, newest first. */
|
|
16
|
+
export interface CleanupList extends CleanupSink {
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Document,
|
|
3
|
+
evaluate,
|
|
4
|
+
type FragmentDecl,
|
|
5
|
+
isConfigDecl,
|
|
6
|
+
isFragmentDecl,
|
|
7
|
+
isLifecycleDecl,
|
|
8
|
+
type LifecycleDecl,
|
|
9
|
+
} from "@venn-lang/core";
|
|
10
|
+
import { createScope } from "../scope/index.js";
|
|
11
|
+
|
|
12
|
+
/** Top-level lifecycle blocks, grouped by hook. */
|
|
13
|
+
export interface SuiteHooks {
|
|
14
|
+
setup: LifecycleDecl[];
|
|
15
|
+
teardown: LifecycleDecl[];
|
|
16
|
+
beforeEach: LifecycleDecl[];
|
|
17
|
+
afterEach: LifecycleDecl[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Index the document's fragments by name for `run`. */
|
|
21
|
+
export function collectFragments(doc: Document): Map<string, FragmentDecl> {
|
|
22
|
+
const map = new Map<string, FragmentDecl>();
|
|
23
|
+
for (const decl of doc.decls) {
|
|
24
|
+
if (isFragmentDecl(decl)) map.set(decl.name, decl);
|
|
25
|
+
}
|
|
26
|
+
return map;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Evaluate the top-level `config { … }` block with `env` bound, for the action context. */
|
|
30
|
+
export function collectConfig(
|
|
31
|
+
doc: Document,
|
|
32
|
+
env: Record<string, unknown>,
|
|
33
|
+
): Record<string, unknown> {
|
|
34
|
+
const decl = doc.decls.find(isConfigDecl);
|
|
35
|
+
if (!decl) return {};
|
|
36
|
+
const scope = createScope();
|
|
37
|
+
scope.set("env", env);
|
|
38
|
+
return evaluate(decl.body, scope) as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Gather top-level setup/teardown/beforeEach/afterEach blocks. */
|
|
42
|
+
export function collectHooks(doc: Document): SuiteHooks {
|
|
43
|
+
const hooks: SuiteHooks = { setup: [], teardown: [], beforeEach: [], afterEach: [] };
|
|
44
|
+
for (const decl of doc.decls) {
|
|
45
|
+
if (isLifecycleDecl(decl)) addHook(hooks, decl);
|
|
46
|
+
}
|
|
47
|
+
return hooks;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Gather the `on <event> { … }` handlers a flow declared, for one event. */
|
|
51
|
+
export function collectOn(stmts: readonly unknown[], event: string): LifecycleDecl[] {
|
|
52
|
+
return stmts.filter(
|
|
53
|
+
(stmt): stmt is LifecycleDecl => isLifecycleDecl(stmt) && stmt.event === event,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addHook(hooks: SuiteHooks, decl: LifecycleDecl): void {
|
|
58
|
+
const bucket = hooks[decl.hook as keyof SuiteHooks];
|
|
59
|
+
if (bucket) bucket.push(decl);
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Run `task` over `items` with at most `limit` in flight at once. */
|
|
2
|
+
export async function runPool<T>(
|
|
3
|
+
items: readonly T[],
|
|
4
|
+
limit: number,
|
|
5
|
+
task: (item: T, index: number) => Promise<void>,
|
|
6
|
+
): Promise<void> {
|
|
7
|
+
const size = Math.max(1, limit);
|
|
8
|
+
let cursor = 0;
|
|
9
|
+
const worker = async (): Promise<void> => {
|
|
10
|
+
while (cursor < items.length) {
|
|
11
|
+
const index = cursor++;
|
|
12
|
+
await task(items[index] as T, index);
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
await Promise.all(Array.from({ length: Math.min(size, items.length) }, worker));
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Cleanup, CleanupList } from "./cleanup.types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The runtime's own sink, for a host that does not bring one.
|
|
5
|
+
*
|
|
6
|
+
* Closing runs newest first and survives a cleanup that throws: a program on its
|
|
7
|
+
* way out still has to hand back everything else it was holding.
|
|
8
|
+
*/
|
|
9
|
+
export function createCleanupList(): CleanupList {
|
|
10
|
+
const pending: Cleanup[] = [];
|
|
11
|
+
return {
|
|
12
|
+
add: (cleanup) => pending.push(cleanup),
|
|
13
|
+
close: async () => {
|
|
14
|
+
for (const cleanup of pending.splice(0).reverse()) await cleanup().catch(() => {});
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ActionDefinition } from "@venn-lang/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How many positional arguments a verb declared.
|
|
5
|
+
*
|
|
6
|
+
* Used to tell an argument that happens to be a map from the options map that
|
|
7
|
+
* configures the call. A variadic verb such as `data.oneOf a b c` takes every
|
|
8
|
+
* argument it is given, so nothing is left over to mistake for configuration.
|
|
9
|
+
*/
|
|
10
|
+
export function takes(action: ActionDefinition): number {
|
|
11
|
+
if (action.args?.some((each) => each.rest)) return Number.POSITIVE_INFINITY;
|
|
12
|
+
return action.args?.length ?? action.signature?.params.length ?? 0;
|
|
13
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Clock, LockProvider } from "@venn-lang/contracts";
|
|
2
|
+
import type { FragmentDecl } from "@venn-lang/core";
|
|
3
|
+
import type { ActionContext } from "@venn-lang/sdk";
|
|
4
|
+
import type { Emitter } from "../emit/index.js";
|
|
5
|
+
import type { Registry } from "../registry/index.js";
|
|
6
|
+
import type { ImportGraph } from "./bind-imports.js";
|
|
7
|
+
import type { CleanupSink } from "./cleanup.types.js";
|
|
8
|
+
import type { RunFilter } from "./filter.types.js";
|
|
9
|
+
import type { FlakyTally } from "./flaky.types.js";
|
|
10
|
+
|
|
11
|
+
/** Mutable pass/fail tally accumulated across a run. */
|
|
12
|
+
export interface RunCounters {
|
|
13
|
+
passed: number;
|
|
14
|
+
failed: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The shared state every `run-*` step reads from. */
|
|
18
|
+
export interface Engine {
|
|
19
|
+
registry: Registry;
|
|
20
|
+
emitter: Emitter;
|
|
21
|
+
ctx: ActionContext;
|
|
22
|
+
clock: Clock;
|
|
23
|
+
lock: LockProvider;
|
|
24
|
+
uri: string;
|
|
25
|
+
result: RunCounters;
|
|
26
|
+
fragments: Map<string, FragmentDecl>;
|
|
27
|
+
/**
|
|
28
|
+
* How to reach what the imported files published: their modules, and how a
|
|
29
|
+
* specifier names one. Absent when nothing was imported, which is most runs.
|
|
30
|
+
*/
|
|
31
|
+
imports?: ImportGraph;
|
|
32
|
+
/** `use "…" as h` aliases, mapped to the namespace each package contributes. */
|
|
33
|
+
aliases: ReadonlyMap<string, string>;
|
|
34
|
+
/** `@flaky(ratio)` tallies per annotated node, settled at the end of the run. */
|
|
35
|
+
flaky: Map<object, FlakyTally>;
|
|
36
|
+
/** Set on a `race` branch: once aborted, the branch stops at its next statement. */
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** What a script-mode program owes the machine when it ends. */
|
|
39
|
+
cleanup: CleanupSink;
|
|
40
|
+
env: Record<string, unknown>;
|
|
41
|
+
/** Which flows and steps to include (`--tags`, `--flow`, `--step`). */
|
|
42
|
+
filter: RunFilter;
|
|
43
|
+
/** Stop after the first flow that fails. */
|
|
44
|
+
bail?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The code an `exit` asked to leave with, unset until one runs. Read by the
|
|
47
|
+
* host: `exit 0` ends a run cleanly however many flows were left.
|
|
48
|
+
*/
|
|
49
|
+
exit?: number;
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Which flows and steps a run includes. Absent fields mean "no restriction". */
|
|
2
|
+
export interface RunFilter {
|
|
3
|
+
/** Keep flows carrying at least one of these `@tags`. */
|
|
4
|
+
tags?: readonly string[];
|
|
5
|
+
/** Keep flows whose title contains this text, case-insensitively. */
|
|
6
|
+
flow?: string;
|
|
7
|
+
/** Keep steps whose title contains this text, case-insensitively. */
|
|
8
|
+
step?: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Annotated, readFlaky } from "./annotations.js";
|
|
2
|
+
import type { Engine } from "./engine.types.js";
|
|
3
|
+
import type { FlakyTally } from "./flaky.types.js";
|
|
4
|
+
|
|
5
|
+
/** Record one execution of a `@flaky(ratio)` node, and how much it failed by. */
|
|
6
|
+
export function recordFlaky(engine: Engine, node: Annotated, before: number): void {
|
|
7
|
+
const ratio = readFlaky(node);
|
|
8
|
+
if (ratio === undefined) return;
|
|
9
|
+
const tally = engine.flaky.get(node) ?? { ratio, runs: 0, failedRuns: 0, failedUnits: 0 };
|
|
10
|
+
const delta = engine.result.failed - before;
|
|
11
|
+
tally.runs += 1;
|
|
12
|
+
tally.failedRuns += delta > 0 ? 1 : 0;
|
|
13
|
+
tally.failedUnits += delta > 0 ? delta : 0;
|
|
14
|
+
engine.flaky.set(node, tally);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Forgive the failures of every `@flaky` node whose observed failure ratio
|
|
19
|
+
* stayed within its declared tolerance. Settled once, so the verdict does not
|
|
20
|
+
* depend on the order iterations happened to fail in.
|
|
21
|
+
*/
|
|
22
|
+
export function settleFlaky(engine: Engine): void {
|
|
23
|
+
for (const tally of engine.flaky.values()) {
|
|
24
|
+
if (tally.failedRuns === 0 || tally.failedRuns / tally.runs > tally.ratio) continue;
|
|
25
|
+
engine.result.failed = Math.max(0, engine.result.failed - tally.failedUnits);
|
|
26
|
+
engine.emitter.emit({ kind: "log", data: { level: "warn", message: message(tally) } });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function message(tally: FlakyTally): string {
|
|
31
|
+
return `flaky tolerated: ${tally.failedRuns}/${tally.runs} runs failed, within ratio ${tally.ratio}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { absorbExit } from "./absorb-exit.js";
|
|
2
|
+
export { collectAliases, collectBoundNames, collectNamespaces } from "./aliases.js";
|
|
3
|
+
export { bindImports, type ImportGraph } from "./bind-imports.js";
|
|
4
|
+
export type { Cleanup, CleanupList, CleanupSink } from "./cleanup.types.js";
|
|
5
|
+
export { collectConfig, collectFragments, collectOn } from "./collect.js";
|
|
6
|
+
export { createCleanupList } from "./create-cleanup-list.js";
|
|
7
|
+
export type { Engine, RunCounters } from "./engine.types.js";
|
|
8
|
+
export { matchesTitle } from "./filter.js";
|
|
9
|
+
export type { RunFilter } from "./filter.types.js";
|
|
10
|
+
export type { Invocation } from "./invocation.js";
|
|
11
|
+
export { actionTarget, invocationOf } from "./invocation.js";
|
|
12
|
+
export { nodeSource, nodeSpan } from "./node-span.js";
|
|
13
|
+
export { runDocument } from "./run-document.js";
|
|
14
|
+
export { runScript } from "./run-script.js";
|
|
15
|
+
export { PRELUDE, resolveTarget, splitTarget } from "./target.js";
|
|
16
|
+
export { unknownOptions } from "./unknown-option.js";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
type Call,
|
|
4
|
+
type Expr,
|
|
5
|
+
isCall,
|
|
6
|
+
isMember,
|
|
7
|
+
isRef,
|
|
8
|
+
type LetStmt,
|
|
9
|
+
type MapLit,
|
|
10
|
+
} from "@venn-lang/core";
|
|
11
|
+
|
|
12
|
+
/** What running an action needs, whichever syntax spelled it. */
|
|
13
|
+
export interface Invocation {
|
|
14
|
+
target: string;
|
|
15
|
+
args: readonly Expr[];
|
|
16
|
+
opts?: MapLit;
|
|
17
|
+
/**
|
|
18
|
+
* Where the call is written, for a failure with no smaller node to point at.
|
|
19
|
+
* Absent when the invocation *is* the node: a call spelled as a statement.
|
|
20
|
+
*/
|
|
21
|
+
node?: AstNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The dotted path an expression spells, such as `http.post`, or undefined when
|
|
26
|
+
* it is not a plain path. The parser cannot tell a namespace from a field; only
|
|
27
|
+
* the registry can, so the path is carried this far as an expression.
|
|
28
|
+
*/
|
|
29
|
+
export function actionTarget(expr: Expr | undefined): string | undefined {
|
|
30
|
+
if (!expr) return undefined;
|
|
31
|
+
if (isRef(expr)) return expr.name;
|
|
32
|
+
if (!isMember(expr) || expr.optional) return undefined;
|
|
33
|
+
const receiver = actionTarget(expr.receiver);
|
|
34
|
+
return receiver === undefined ? undefined : `${receiver}.${expr.member}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The bareword action a `let` spells: `let auth = http.post url { … }`. The
|
|
39
|
+
* trailing arguments or options map are what mark it as a call. A parenthesised
|
|
40
|
+
* call (`f(x)`) is an ordinary expression the evaluator runs, since it may be a
|
|
41
|
+
* `fn` or a method rather than an action; only {@link actionCall} rescues the
|
|
42
|
+
* case where it turns out to name a plugin action.
|
|
43
|
+
*/
|
|
44
|
+
export function invocationOf(stmt: LetStmt): Invocation | undefined {
|
|
45
|
+
return remember(invocations, stmt, readInvocation);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readInvocation(stmt: LetStmt): Invocation | undefined {
|
|
49
|
+
if (stmt.args.length === 0 && !stmt.opts) return undefined;
|
|
50
|
+
const target = actionTarget(stmt.value);
|
|
51
|
+
if (target === undefined) return undefined;
|
|
52
|
+
return { target, args: stmt.args, opts: stmt.opts, node: stmt };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* An action written in expression position: the bare path `data.faker.uuid`, or
|
|
57
|
+
* the parenthesised `data.faker.uuid()`. Returns the target and its arguments,
|
|
58
|
+
* or undefined when it is not a dotted path at all.
|
|
59
|
+
*/
|
|
60
|
+
export function actionCall(value: Expr): ActionShape | undefined {
|
|
61
|
+
return remember(calls, value, readActionCall);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readActionCall(value: Expr): ActionShape | undefined {
|
|
65
|
+
const bare = actionTarget(value);
|
|
66
|
+
if (bare !== undefined) return { target: bare, args: [] };
|
|
67
|
+
if (!isCall(value)) return undefined;
|
|
68
|
+
const target = actionTarget(value.callee);
|
|
69
|
+
return target === undefined ? undefined : { target, args: argValues(value) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An action in expression position: the path it names and its arguments. */
|
|
73
|
+
export interface ActionShape {
|
|
74
|
+
target: string;
|
|
75
|
+
args: readonly Expr[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What a node spells, worked out once.
|
|
80
|
+
*
|
|
81
|
+
* The shape of the tree decides whether `let y = x * 2` names an action, and the
|
|
82
|
+
* tree does not change, so the answer is cached per node rather than rewalked
|
|
83
|
+
* through the type guards on every execution. Whether the *name* resolves to an
|
|
84
|
+
* action depends on the scope and the registry, so that part is not cached.
|
|
85
|
+
*/
|
|
86
|
+
const invocations = new WeakMap<LetStmt, Invocation | null>();
|
|
87
|
+
const calls = new WeakMap<Expr, ActionShape | null>();
|
|
88
|
+
|
|
89
|
+
function remember<K extends object, V>(
|
|
90
|
+
cache: WeakMap<K, V | null>,
|
|
91
|
+
key: K,
|
|
92
|
+
read: (key: K) => V | undefined,
|
|
93
|
+
): V | undefined {
|
|
94
|
+
const known = cache.get(key);
|
|
95
|
+
if (known !== undefined) return known ?? undefined;
|
|
96
|
+
const found = read(key) ?? null;
|
|
97
|
+
cache.set(key, found);
|
|
98
|
+
return found ?? undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function argValues(call: Call): Expr[] {
|
|
102
|
+
return (call.args?.args ?? []).map((arg) => arg.value);
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { isCallable, isNamespaceValue, memberValue } from "@venn-lang/core";
|
|
2
|
+
import type { Scope } from "../scope/index.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a dotted statement target names, when it names something in scope.
|
|
6
|
+
*
|
|
7
|
+
* `conn.close()` and `http.get "url"` are written alike and mean different
|
|
8
|
+
* things: one is a method on a value the program holds, the other a verb a
|
|
9
|
+
* plugin contributes. Only the name can tell them apart, and a name the program
|
|
10
|
+
* bound wins. That is the rule the evaluator already follows for `auth` the
|
|
11
|
+
* variable against `auth` the namespace.
|
|
12
|
+
*
|
|
13
|
+
* Returns undefined whenever this is not that: no such name, a namespace, or a
|
|
14
|
+
* path that leads somewhere uncallable. The caller then treats it as a verb,
|
|
15
|
+
* and an unknown verb reports itself as it always did.
|
|
16
|
+
*/
|
|
17
|
+
export function localCallee(target: string, scope: Scope): unknown {
|
|
18
|
+
const segments = target.split(".");
|
|
19
|
+
const head = segments[0];
|
|
20
|
+
if (!head) return undefined;
|
|
21
|
+
const root = scope.lookup(head);
|
|
22
|
+
if (root === undefined || isNamespaceValue(root)) return undefined;
|
|
23
|
+
let value: unknown = root;
|
|
24
|
+
for (let at = 1; at < segments.length; at += 1) {
|
|
25
|
+
value = memberValue(value, segments[at] as string);
|
|
26
|
+
}
|
|
27
|
+
return isCallable(value) ? value : undefined;
|
|
28
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Span } from "@venn-lang/core";
|
|
2
|
+
|
|
3
|
+
/** Minimal structural view of a Langium CST node (avoids importing langium). */
|
|
4
|
+
interface CstView {
|
|
5
|
+
offset?: number;
|
|
6
|
+
length?: number;
|
|
7
|
+
text?: string;
|
|
8
|
+
range?: { start?: { line?: number; character?: number } };
|
|
9
|
+
}
|
|
10
|
+
interface WithCst {
|
|
11
|
+
$cstNode?: CstView;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The exact source text of an AST node, for the event stream. */
|
|
15
|
+
export function nodeSource(node: WithCst): string {
|
|
16
|
+
return node.$cstNode?.text ?? "";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A Span for an AST node, for a runtime Problem. */
|
|
20
|
+
export function nodeSpan(node: WithCst, uri: string): Span {
|
|
21
|
+
const cst = node.$cstNode;
|
|
22
|
+
return {
|
|
23
|
+
uri,
|
|
24
|
+
offset: cst?.offset ?? 0,
|
|
25
|
+
length: cst?.length ?? 0,
|
|
26
|
+
line: (cst?.range?.start?.line ?? 0) + 1,
|
|
27
|
+
column: (cst?.range?.start?.character ?? 0) + 1,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { evaluate, type MapLit } from "@venn-lang/core";
|
|
2
|
+
import type { Scope } from "../scope/index.js";
|
|
3
|
+
|
|
4
|
+
/** Read a string field from an options map literal (e.g. `{ onError: "cancel" }`). */
|
|
5
|
+
export function optsText(opts: MapLit | undefined, key: string, scope: Scope): string | undefined {
|
|
6
|
+
const entry = opts?.entries.find((candidate) => candidate.key === key);
|
|
7
|
+
if (!entry) return undefined;
|
|
8
|
+
const value = evaluate(entry.value, scope);
|
|
9
|
+
return typeof value === "string" ? value : undefined;
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { evaluate, type MapLit } from "@venn-lang/core";
|
|
2
|
+
import type { Scope } from "../scope/index.js";
|
|
3
|
+
|
|
4
|
+
/** Read a numeric field from an options map literal (e.g. `{ concurrency: 4 }`). */
|
|
5
|
+
export function optsNumber(
|
|
6
|
+
opts: MapLit | undefined,
|
|
7
|
+
key: string,
|
|
8
|
+
scope: Scope,
|
|
9
|
+
): number | undefined {
|
|
10
|
+
const entry = opts?.entries.find((candidate) => candidate.key === key);
|
|
11
|
+
if (!entry) return undefined;
|
|
12
|
+
const value = evaluate(entry.value, scope);
|
|
13
|
+
return typeof value === "number" ? value : undefined;
|
|
14
|
+
}
|