@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,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActionCall,
|
|
3
|
+
type AstNode,
|
|
4
|
+
buildProblem,
|
|
5
|
+
type CaptureStmt,
|
|
6
|
+
CODES,
|
|
7
|
+
type LetStmt,
|
|
8
|
+
type MapLit,
|
|
9
|
+
type Problem,
|
|
10
|
+
} from "@venn-lang/core";
|
|
11
|
+
import { actionTarget, nodeSpan, PRELUDE, resolveTarget, splitTarget } from "../scheduler/index.js";
|
|
12
|
+
import type { CheckContext } from "./check.types.js";
|
|
13
|
+
import { checkOptions } from "./check-options.js";
|
|
14
|
+
|
|
15
|
+
/** `http.get "…"` written as a statement. */
|
|
16
|
+
export function checkAction(call: ActionCall, ctx: CheckContext): Problem[] {
|
|
17
|
+
return checkTarget({ node: call, target: call.target, opts: call.opts, ctx });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `let auth = http.post url { … }`. Only the unmistakable call is checked: with
|
|
22
|
+
* no arguments the path could be a field of a variable this pass cannot see, and
|
|
23
|
+
* a false "unknown action" is worse than a missed one.
|
|
24
|
+
*/
|
|
25
|
+
export function checkLet(stmt: LetStmt, ctx: CheckContext): Problem[] {
|
|
26
|
+
if (stmt.args.length === 0 && !stmt.opts) return [];
|
|
27
|
+
const target = actionTarget(stmt.value);
|
|
28
|
+
if (target === undefined) {
|
|
29
|
+
return [problem(stmt, ctx, CODES.VN2003_UNKNOWN_ACTION, "This is not an action to call.")];
|
|
30
|
+
}
|
|
31
|
+
return checkTarget({ node: stmt, target, opts: stmt.opts, ctx });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** `capture` is folded into `let`; say so where it is written. */
|
|
35
|
+
export function checkCapture(stmt: CaptureStmt, ctx: CheckContext): Problem {
|
|
36
|
+
const title =
|
|
37
|
+
"`capture` was removed — use `let` for a value that changes, `const` for one that does not.";
|
|
38
|
+
return problem(stmt, ctx, CODES.VN5001_REMOVED_KEYWORD, title);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A namespace is only usable when this file brought it in with `use` (or it is
|
|
43
|
+
* a resource). Loading the whole stdlib must not make `use` optional.
|
|
44
|
+
*/
|
|
45
|
+
function checkTarget(args: {
|
|
46
|
+
node: AstNode;
|
|
47
|
+
target: string;
|
|
48
|
+
opts: MapLit | undefined;
|
|
49
|
+
ctx: CheckContext;
|
|
50
|
+
}): Problem[] {
|
|
51
|
+
const { node, target, ctx } = args;
|
|
52
|
+
if (PRELUDE.has(target)) return [];
|
|
53
|
+
const written = splitTarget(target).namespace;
|
|
54
|
+
if (ctx.bound.has(written)) return [];
|
|
55
|
+
if (!ctx.imported.has(written)) return [missingImport(args, written)];
|
|
56
|
+
const resolved = ctx.registry.action(resolveTarget(target, ctx.aliases));
|
|
57
|
+
if (!resolved) {
|
|
58
|
+
return [problem(node, ctx, CODES.VN2003_UNKNOWN_ACTION, `Unknown action "${target}".`)];
|
|
59
|
+
}
|
|
60
|
+
return checkOptions({ opts: args.opts, params: resolved.action.params, ctx });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function missingImport(
|
|
64
|
+
args: { node: AstNode; target: string; ctx: CheckContext },
|
|
65
|
+
namespace: string,
|
|
66
|
+
): Problem {
|
|
67
|
+
if (!args.ctx.registry.hasNamespace(namespace)) {
|
|
68
|
+
const title = `Unknown action "${args.target}" — no loaded plugin provides it.`;
|
|
69
|
+
return problem(args.node, args.ctx, CODES.VN2003_UNKNOWN_ACTION, title);
|
|
70
|
+
}
|
|
71
|
+
const title = `"${namespace}" is not imported in this file — add a \`use\` for its package.`;
|
|
72
|
+
return problem(args.node, args.ctx, CODES.VN2007_NAMESPACE_NOT_IMPORTED, title);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function problem(
|
|
76
|
+
node: AstNode,
|
|
77
|
+
ctx: CheckContext,
|
|
78
|
+
spec: (typeof CODES)[keyof typeof CODES],
|
|
79
|
+
title: string,
|
|
80
|
+
): Problem {
|
|
81
|
+
return buildProblem({ spec, span: nodeSpan(node, ctx.uri), title });
|
|
82
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
type DecoDecl,
|
|
6
|
+
decoCannotCall,
|
|
7
|
+
isActionCall,
|
|
8
|
+
isDecoDecl,
|
|
9
|
+
isLetStmt,
|
|
10
|
+
type Problem,
|
|
11
|
+
} from "@venn-lang/core";
|
|
12
|
+
import { actionTarget, nodeSpan, PRELUDE, splitTarget } from "../scheduler/index.js";
|
|
13
|
+
import type { CheckContext } from "./check.types.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check a node written inside a `deco` body, which is not the program and so is
|
|
17
|
+
* not resolved against the registry: `target.wrap(f)` is a verb on the handle
|
|
18
|
+
* the body was handed, and expansion settles what the rest means. The one
|
|
19
|
+
* refusal this pass can make is a verb from a plugin, which cannot work in a
|
|
20
|
+
* `deco` because a decorator runs before the program exists.
|
|
21
|
+
*
|
|
22
|
+
* @returns The node's problems, or `undefined` when the node is not inside a
|
|
23
|
+
* `deco`, which tells the caller to apply the ordinary document checks instead.
|
|
24
|
+
*/
|
|
25
|
+
export function checkInsideDeco(node: AstNode, ctx: CheckContext): Problem[] | undefined {
|
|
26
|
+
const deco = enclosingDeco(node);
|
|
27
|
+
if (!deco) return undefined;
|
|
28
|
+
const target = calledTarget(node);
|
|
29
|
+
const refused = target === undefined ? undefined : pluginVerb({ deco, target, ctx });
|
|
30
|
+
return refused ? [problem(node, ctx, refused)] : [];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The `deco` this node is written inside, if any. */
|
|
34
|
+
function enclosingDeco(node: AstNode): DecoDecl | undefined {
|
|
35
|
+
for (let at: AstNode | undefined = node; at; at = at.$container) {
|
|
36
|
+
if (isDecoDecl(at)) return at;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The dotted name this statement calls, if calling is what it does. */
|
|
42
|
+
function calledTarget(node: AstNode): string | undefined {
|
|
43
|
+
if (isActionCall(node)) return node.target;
|
|
44
|
+
if (!isLetStmt(node) || (node.args.length === 0 && !node.opts)) return undefined;
|
|
45
|
+
return actionTarget(node.value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why this call cannot be made from here, if it cannot. Only a head the file can
|
|
50
|
+
* already account for is refused: an imported namespace, a resource, a namespace
|
|
51
|
+
* some loaded plugin owns. Anything else is a name only expansion resolves, and
|
|
52
|
+
* guessing at it would put an error on every well-written decorator.
|
|
53
|
+
*/
|
|
54
|
+
function pluginVerb(args: {
|
|
55
|
+
deco: DecoDecl;
|
|
56
|
+
target: string;
|
|
57
|
+
ctx: CheckContext;
|
|
58
|
+
}): string | undefined {
|
|
59
|
+
const { namespace } = splitTarget(args.target);
|
|
60
|
+
if (PRELUDE.has(args.target) || paramNames(args.deco).has(namespace)) return undefined;
|
|
61
|
+
if (!reachesTheWorld(namespace, args.ctx)) return undefined;
|
|
62
|
+
return decoCannotCall(args.target);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function reachesTheWorld(namespace: string, ctx: CheckContext): boolean {
|
|
66
|
+
return (
|
|
67
|
+
ctx.imported.has(namespace) || ctx.bound.has(namespace) || ctx.registry.hasNamespace(namespace)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function paramNames(deco: DecoDecl): Set<string> {
|
|
72
|
+
return new Set((deco.params?.params ?? []).map((param) => param.name));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function problem(node: AstNode, ctx: CheckContext, title: string): Problem {
|
|
76
|
+
return buildProblem({ spec: CODES.VN2016_DECO_IMPURE, span: nodeSpan(node, ctx.uri), title });
|
|
77
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
isActionCall,
|
|
6
|
+
isCall,
|
|
7
|
+
isCaptureStmt,
|
|
8
|
+
isLetStmt,
|
|
9
|
+
isMatcherClause,
|
|
10
|
+
isRunStmt,
|
|
11
|
+
type MatcherClause,
|
|
12
|
+
type Problem,
|
|
13
|
+
type RunStmt,
|
|
14
|
+
walkAst,
|
|
15
|
+
} from "@venn-lang/core";
|
|
16
|
+
import {
|
|
17
|
+
collectAliases,
|
|
18
|
+
collectBoundNames,
|
|
19
|
+
collectNamespaces,
|
|
20
|
+
nodeSpan,
|
|
21
|
+
} from "../scheduler/index.js";
|
|
22
|
+
import type { CheckArgs, CheckContext } from "./check.types.js";
|
|
23
|
+
import { checkAction, checkCapture, checkLet } from "./check-calls.js";
|
|
24
|
+
import { checkInsideDeco } from "./check-deco-body.js";
|
|
25
|
+
import { checkEnv } from "./check-env.js";
|
|
26
|
+
import { checkFragmentCall } from "./check-fragment-call.js";
|
|
27
|
+
import { checkInterpolation } from "./check-interpolation.js";
|
|
28
|
+
import { checkUncalledAction } from "./check-uncalled.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Statically resolve every action, matcher and fragment reference in a parsed
|
|
32
|
+
* document. The errors the runner would otherwise raise mid-run are surfaced all
|
|
33
|
+
* at once, each with its source span.
|
|
34
|
+
*
|
|
35
|
+
* @param args Document, registry, known fragments and the declared `env` names.
|
|
36
|
+
* @returns One `Problem` per unresolved reference; empty when the document is clean.
|
|
37
|
+
*/
|
|
38
|
+
export function checkDocument(args: CheckArgs): Problem[] {
|
|
39
|
+
const ctx: CheckContext = {
|
|
40
|
+
registry: args.registry,
|
|
41
|
+
fragments: args.fragments,
|
|
42
|
+
aliases: collectAliases(args.document, args.registry),
|
|
43
|
+
imported: collectNamespaces(args.document, args.registry),
|
|
44
|
+
bound: collectBoundNames(args.document),
|
|
45
|
+
env: args.env ? new Set(args.env) : undefined,
|
|
46
|
+
uri: args.uri ?? "memory://inline.vn",
|
|
47
|
+
};
|
|
48
|
+
const problems: Problem[] = [];
|
|
49
|
+
for (const node of walkAst(args.document)) {
|
|
50
|
+
const inDeco = checkInsideDeco(node, ctx);
|
|
51
|
+
if (inDeco) problems.push(...inDeco);
|
|
52
|
+
else problems.push(...everyCheck(node, ctx));
|
|
53
|
+
}
|
|
54
|
+
return problems;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function everyCheck(node: AstNode, ctx: CheckContext): Problem[] {
|
|
58
|
+
return [
|
|
59
|
+
...checkNode(node, ctx),
|
|
60
|
+
...checkEnv(node, ctx),
|
|
61
|
+
...checkInterpolation(node, ctx),
|
|
62
|
+
...one(checkUncalledAction(node, ctx)),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function checkNode(node: AstNode, ctx: CheckContext): Problem[] {
|
|
67
|
+
if (isActionCall(node)) return checkAction(node, ctx);
|
|
68
|
+
if (isLetStmt(node)) return checkLet(node, ctx);
|
|
69
|
+
if (isCaptureStmt(node)) return [checkCapture(node, ctx)];
|
|
70
|
+
if (isMatcherClause(node)) return one(checkMatcher(node, ctx));
|
|
71
|
+
if (isRunStmt(node)) return one(checkFragment(node, ctx));
|
|
72
|
+
if (isCall(node)) return one(checkFragmentCall(node, ctx));
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function one(problem: Problem | undefined): Problem[] {
|
|
77
|
+
return problem ? [problem] : [];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A matcher comes from a plugin like any action, so the file has to bring that
|
|
82
|
+
* plugin in. Resolving against the whole loaded stdlib would make `use` decorative.
|
|
83
|
+
*/
|
|
84
|
+
function checkMatcher(clause: MatcherClause, ctx: CheckContext): Problem | undefined {
|
|
85
|
+
const owner = ctx.registry.matcher(clause.name);
|
|
86
|
+
if (!owner) {
|
|
87
|
+
return problem(clause, ctx, CODES.VN2004_UNKNOWN_MATCHER, `Unknown matcher "${clause.name}".`);
|
|
88
|
+
}
|
|
89
|
+
if (ctx.imported.has(owner.plugin.namespace)) return undefined;
|
|
90
|
+
const title = `"${clause.name}" comes from "${owner.plugin.namespace}", which is not imported in this file.`;
|
|
91
|
+
return problem(clause, ctx, CODES.VN2007_NAMESPACE_NOT_IMPORTED, title);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function checkFragment(stmt: RunStmt, ctx: CheckContext): Problem | undefined {
|
|
95
|
+
if (ctx.fragments.has(stmt.target)) return undefined;
|
|
96
|
+
return problem(stmt, ctx, CODES.VN2005_UNKNOWN_FRAGMENT, `Unknown fragment "${stmt.target}".`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function problem(
|
|
100
|
+
node: AstNode,
|
|
101
|
+
ctx: CheckContext,
|
|
102
|
+
spec: (typeof CODES)[keyof typeof CODES],
|
|
103
|
+
title: string,
|
|
104
|
+
): Problem {
|
|
105
|
+
return buildProblem({ spec, span: nodeSpan(node, ctx.uri), title });
|
|
106
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
isMember,
|
|
6
|
+
type Problem,
|
|
7
|
+
type Span,
|
|
8
|
+
} from "@venn-lang/core";
|
|
9
|
+
import { actionTarget, nodeSpan } from "../scheduler/index.js";
|
|
10
|
+
import type { CheckContext } from "./check.types.js";
|
|
11
|
+
|
|
12
|
+
/** `env.NAME` as it appears inside a `${…}` placeholder. */
|
|
13
|
+
const ENV_READ = /\benv\.([A-Za-z_]\w*)/g;
|
|
14
|
+
|
|
15
|
+
/** Always present: the runner sets it to the `--env` that was selected. */
|
|
16
|
+
const BUILT_IN = new Set(["name"]);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Match every `env.*` read against what `venn.toml` declares, so a typo is an
|
|
20
|
+
* error rather than an empty string and a puzzling 404.
|
|
21
|
+
*
|
|
22
|
+
* Nothing is reported when the manifest could not be read: a wrong error about a
|
|
23
|
+
* variable that does exist is worse than no error at all.
|
|
24
|
+
*/
|
|
25
|
+
export function checkEnv(node: AstNode, ctx: CheckContext): Problem[] {
|
|
26
|
+
if (!isMember(node) || isMember(node.$container)) return [];
|
|
27
|
+
const path = actionTarget(node);
|
|
28
|
+
const name = path?.startsWith("env.") ? path.slice(4) : undefined;
|
|
29
|
+
if (name === undefined || name.includes(".")) return [];
|
|
30
|
+
const span = nodeSpan(node, ctx.uri);
|
|
31
|
+
if (!ctx.imported.has("env")) return [notImported(span)];
|
|
32
|
+
if (!ctx.env || declared(name, ctx.env)) return [];
|
|
33
|
+
return [envProblem(name, span, ctx)];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Configuration is brought in like anything else. Reading `env.*` without saying
|
|
38
|
+
* where it comes from is the same hole `use` closes for actions and matchers:
|
|
39
|
+
* the reader should not have to know which names are magic.
|
|
40
|
+
*/
|
|
41
|
+
function notImported(span: Span): Problem {
|
|
42
|
+
return buildProblem({
|
|
43
|
+
spec: CODES.VN2007_NAMESPACE_NOT_IMPORTED,
|
|
44
|
+
span,
|
|
45
|
+
title: '"env" is not imported in this file — add `use "venn/env"`.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The same checks for text the parser never turned into nodes: `"${env.X}"`. */
|
|
50
|
+
export function envProblemsIn(source: string, span: Span, ctx: CheckContext): Problem[] {
|
|
51
|
+
const names = [...source.matchAll(ENV_READ)].map((match) => match[1] ?? "").filter(Boolean);
|
|
52
|
+
if (names.length === 0) return [];
|
|
53
|
+
if (!ctx.imported.has("env")) return [notImported(span)];
|
|
54
|
+
const env = ctx.env;
|
|
55
|
+
if (!env) return [];
|
|
56
|
+
return names.filter((name) => !declared(name, env)).map((name) => envProblem(name, span, ctx));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function declared(name: string, env: ReadonlySet<string>): boolean {
|
|
60
|
+
return env.has(name) || BUILT_IN.has(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The undeclared-variable problem, with the nearest declared name as a hint. */
|
|
64
|
+
export function envProblem(name: string, span: Span, ctx: CheckContext): Problem {
|
|
65
|
+
const hint = nearest(name, [...(ctx.env ?? [])]);
|
|
66
|
+
const title = hint
|
|
67
|
+
? `"env.${name}" is not declared in venn.toml — did you mean "env.${hint}"?`
|
|
68
|
+
: `"env.${name}" is not declared in venn.toml.`;
|
|
69
|
+
return buildProblem({ spec: CODES.VN2006_UNKNOWN_ENV, span, title });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function nearest(name: string, known: readonly string[]): string | undefined {
|
|
73
|
+
const best = known
|
|
74
|
+
.map((candidate) => ({ candidate, distance: distance(name, candidate) }))
|
|
75
|
+
.sort((left, right) => left.distance - right.distance)[0];
|
|
76
|
+
const tolerance = Math.max(2, Math.floor(name.length / 3));
|
|
77
|
+
return best && best.distance <= tolerance ? best.candidate : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Levenshtein edit distance, kept to two rows because only the score is needed. */
|
|
81
|
+
function distance(left: string, right: string): number {
|
|
82
|
+
let previous = Array.from({ length: right.length + 1 }, (_value, index) => index);
|
|
83
|
+
for (let i = 1; i <= left.length; i += 1) {
|
|
84
|
+
const current = [i];
|
|
85
|
+
for (let j = 1; j <= right.length; j += 1) {
|
|
86
|
+
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
|
|
87
|
+
current[j] = Math.min(
|
|
88
|
+
(current[j - 1] ?? 0) + 1,
|
|
89
|
+
(previous[j] ?? 0) + 1,
|
|
90
|
+
(previous[j - 1] ?? 0) + cost,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
previous = current;
|
|
94
|
+
}
|
|
95
|
+
return previous[right.length] ?? 0;
|
|
96
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { buildProblem, type Call, CODES, isRef, type Problem } from "@venn-lang/core";
|
|
2
|
+
import { nodeSpan } from "../scheduler/index.js";
|
|
3
|
+
import type { CheckContext } from "./check.types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Refuse a fragment called as though it were a function.
|
|
7
|
+
*
|
|
8
|
+
* The two look alike where they are written but are different kinds of thing: a
|
|
9
|
+
* `fn` gives back a value, a `fragment` gives back steps, and steps are recorded
|
|
10
|
+
* in the report, can fail and belong to a flow. So a fragment is invoked with
|
|
11
|
+
* `run`. `run entrar(…)` never reaches here: its target is a name, not an
|
|
12
|
+
* expression.
|
|
13
|
+
*
|
|
14
|
+
* @param call The call expression to inspect.
|
|
15
|
+
* @param ctx The document's resolved check context.
|
|
16
|
+
* @returns A `VN3013` problem, or `undefined` when the callee is not a fragment.
|
|
17
|
+
*/
|
|
18
|
+
export function checkFragmentCall(call: Call, ctx: CheckContext): Problem | undefined {
|
|
19
|
+
const callee = call.callee;
|
|
20
|
+
if (!isRef(callee) || !ctx.fragments.has(callee.name)) return undefined;
|
|
21
|
+
return buildProblem({
|
|
22
|
+
spec: CODES.VN3013_NOT_CALLABLE,
|
|
23
|
+
span: nodeSpan(call, ctx.uri),
|
|
24
|
+
title: `${callee.name} is a fragment, so it cannot be called for a value.`,
|
|
25
|
+
note: `Invoke it with \`run ${callee.name}(…)\`, which records its steps in the report.`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildProblem,
|
|
3
|
+
CODES,
|
|
4
|
+
type Document,
|
|
5
|
+
isDecoDecl,
|
|
6
|
+
isFnDecl,
|
|
7
|
+
isFragmentDecl,
|
|
8
|
+
isValueImport,
|
|
9
|
+
type Problem,
|
|
10
|
+
type ValueImport,
|
|
11
|
+
} from "@venn-lang/core";
|
|
12
|
+
import type { ImportGraph } from "../scheduler/index.js";
|
|
13
|
+
import { nodeSpan } from "../scheduler/index.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check every name a file imports against what the file it names publishes.
|
|
17
|
+
* Otherwise a misspelt import stays quietly `undefined` until something calls
|
|
18
|
+
* it, and the run blames the call site rather than the import.
|
|
19
|
+
*
|
|
20
|
+
* @param args The importing document, its URI, and the resolved import graph.
|
|
21
|
+
* @returns One `VN2009` problem per name the target does not publish.
|
|
22
|
+
*/
|
|
23
|
+
export function checkImports(args: {
|
|
24
|
+
document: Document;
|
|
25
|
+
uri: string;
|
|
26
|
+
graph: ImportGraph;
|
|
27
|
+
}): Problem[] {
|
|
28
|
+
const problems: Problem[] = [];
|
|
29
|
+
for (const decl of args.document.imports) {
|
|
30
|
+
if (!isValueImport(decl)) continue;
|
|
31
|
+
const target = args.graph.resolve(args.uri, decl.path);
|
|
32
|
+
const module = args.graph.modules.get(target);
|
|
33
|
+
// A path that reads nothing is already reported by whoever tried to read it.
|
|
34
|
+
if (module) problems.push(...missing({ decl, module, uri: args.uri, path: decl.path }));
|
|
35
|
+
}
|
|
36
|
+
return problems;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function missing(args: {
|
|
40
|
+
decl: ValueImport;
|
|
41
|
+
module: Document;
|
|
42
|
+
uri: string;
|
|
43
|
+
path: string;
|
|
44
|
+
}): Problem[] {
|
|
45
|
+
const published = exported(args.module);
|
|
46
|
+
return args.decl.names
|
|
47
|
+
.filter((name) => !published.has(name))
|
|
48
|
+
.map((name) =>
|
|
49
|
+
buildProblem({
|
|
50
|
+
spec: CODES.VN2009_NOT_EXPORTED,
|
|
51
|
+
span: nodeSpan(args.decl, args.uri),
|
|
52
|
+
title: `"${args.path}" does not publish ${name}.`,
|
|
53
|
+
note: hint(name, args.module),
|
|
54
|
+
}),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Why it is not there: written but kept private, or not written at all. The two
|
|
60
|
+
* are different mistakes with different fixes, and a reader told only "not
|
|
61
|
+
* published" goes looking for a typo they did not make.
|
|
62
|
+
*/
|
|
63
|
+
function hint(name: string, module: Document): string {
|
|
64
|
+
const declared = module.decls.some(
|
|
65
|
+
(decl) =>
|
|
66
|
+
(isFnDecl(decl) || isFragmentDecl(decl) || isDecoDecl(decl)) &&
|
|
67
|
+
(decl as { name?: string }).name === name,
|
|
68
|
+
);
|
|
69
|
+
return declared
|
|
70
|
+
? `It is declared there, but not marked \`pub\`.`
|
|
71
|
+
: `Nothing of that name is declared there.`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Everything a file marked `pub`: functions, fragments and decorators alike. */
|
|
75
|
+
function exported(document: Document): Set<string> {
|
|
76
|
+
const names = new Set<string>();
|
|
77
|
+
for (const decl of document.decls) {
|
|
78
|
+
if (!isFnDecl(decl) && !isFragmentDecl(decl) && !isDecoDecl(decl)) continue;
|
|
79
|
+
if (decl.export) names.add(decl.name);
|
|
80
|
+
}
|
|
81
|
+
return names;
|
|
82
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
type InterpolationSlot,
|
|
6
|
+
isStringLit,
|
|
7
|
+
type Problem,
|
|
8
|
+
parseExpression,
|
|
9
|
+
type Span,
|
|
10
|
+
scanInterpolations,
|
|
11
|
+
} from "@venn-lang/core";
|
|
12
|
+
import type { CheckContext } from "./check.types.js";
|
|
13
|
+
import { envProblemsIn } from "./check-env.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Check each `${…}` placeholder in a string literal and point at the placeholder
|
|
17
|
+
* itself, not at the whole string. A slot that does not parse would otherwise
|
|
18
|
+
* evaluate to an empty string, so a typo inside a URL fails as a puzzling 404.
|
|
19
|
+
*/
|
|
20
|
+
export function checkInterpolation(node: AstNode, ctx: CheckContext): Problem[] {
|
|
21
|
+
const cst = node.$cstNode;
|
|
22
|
+
if (!isStringLit(node) || !cst) return [];
|
|
23
|
+
return scanInterpolations(cst.text).flatMap((slot) =>
|
|
24
|
+
inSlot(slot, spanOf(slot, { cst, uri: ctx.uri }), ctx),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A URL is where `env` reads live, so they are checked here as well as in the AST. */
|
|
29
|
+
function inSlot(slot: InterpolationSlot, span: Span, ctx: CheckContext): Problem[] {
|
|
30
|
+
if (!parseExpression(slot.source)) return [unreadable(slot, span)];
|
|
31
|
+
return envProblemsIn(slot.source, span, ctx);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unreadable(slot: InterpolationSlot, span: Span): Problem {
|
|
35
|
+
return buildProblem({
|
|
36
|
+
spec: CODES.VN1002_PARSE,
|
|
37
|
+
span,
|
|
38
|
+
title: `Cannot read \`\${${slot.source}}\` — that is not an expression.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The span of the placeholder itself: line and column follow the string's own start. */
|
|
43
|
+
function spanOf(
|
|
44
|
+
slot: InterpolationSlot,
|
|
45
|
+
target: {
|
|
46
|
+
cst: { offset: number; range?: { start: { line: number; character: number } } };
|
|
47
|
+
uri: string;
|
|
48
|
+
},
|
|
49
|
+
): Span {
|
|
50
|
+
const start = target.cst.range?.start;
|
|
51
|
+
return {
|
|
52
|
+
uri: target.uri,
|
|
53
|
+
offset: target.cst.offset + slot.start,
|
|
54
|
+
length: slot.end - slot.start,
|
|
55
|
+
line: (start?.line ?? 0) + 1,
|
|
56
|
+
column: (start?.character ?? 0) + 1 + slot.start,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { MapLit, Problem } from "@venn-lang/core";
|
|
2
|
+
import { unknownOptions } from "../scheduler/index.js";
|
|
3
|
+
import type { CheckContext } from "./check.types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check the keys of an options map against the action's own schema.
|
|
7
|
+
*
|
|
8
|
+
* The runtime refuses an unknown key too, but only when the flow reaches that
|
|
9
|
+
* line. Both read the same list and say the same sentence, so the editor can
|
|
10
|
+
* mark the word before anything runs.
|
|
11
|
+
*/
|
|
12
|
+
export function checkOptions(args: {
|
|
13
|
+
opts: MapLit | undefined;
|
|
14
|
+
params: unknown;
|
|
15
|
+
ctx: CheckContext;
|
|
16
|
+
}): Problem[] {
|
|
17
|
+
return unknownOptions({ opts: args.opts, params: args.params, uri: args.ctx.uri });
|
|
18
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AstNode,
|
|
3
|
+
buildProblem,
|
|
4
|
+
CODES,
|
|
5
|
+
isCall,
|
|
6
|
+
isLetStmt,
|
|
7
|
+
isMember,
|
|
8
|
+
type Member,
|
|
9
|
+
type Problem,
|
|
10
|
+
} from "@venn-lang/core";
|
|
11
|
+
import { actionTarget, nodeSpan, resolveTarget } from "../scheduler/index.js";
|
|
12
|
+
import type { CheckContext } from "./check.types.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A plugin verb named but never called.
|
|
16
|
+
*
|
|
17
|
+
* `let id = data.faker.uuid` runs the verb, because the runtime recognises a
|
|
18
|
+
* bare path that names an action. The same words inside an expression evaluate
|
|
19
|
+
* to the verb itself, so a program meaning to read a value gets a function.
|
|
20
|
+
* Rather than guess which was meant, this asks for the parentheses.
|
|
21
|
+
*/
|
|
22
|
+
export function checkUncalledAction(node: AstNode, ctx: CheckContext): Problem | undefined {
|
|
23
|
+
if (!isMember(node) || !readsAsValue(node)) return undefined;
|
|
24
|
+
const target = actionTarget(node);
|
|
25
|
+
if (target === undefined) return undefined;
|
|
26
|
+
if (!ctx.registry.action(resolveTarget(target, ctx.aliases))) return undefined;
|
|
27
|
+
return buildProblem({
|
|
28
|
+
spec: CODES.VN2008_UNCALLED_ACTION,
|
|
29
|
+
span: nodeSpan(node, ctx.uri),
|
|
30
|
+
title: `\`${target}\` is a verb, not a value — write \`${target}()\` to call it.`,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where naming the verb produces the verb. Skipped when it is the head of a
|
|
36
|
+
* longer path or the thing being called, and when it stands as a statement's
|
|
37
|
+
* value, which the runtime calls anyway.
|
|
38
|
+
*/
|
|
39
|
+
function readsAsValue(node: Member): boolean {
|
|
40
|
+
const parent = node.$container;
|
|
41
|
+
return !isMember(parent) && !isCall(parent) && !isLetStmt(parent);
|
|
42
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Document } from "@venn-lang/core";
|
|
2
|
+
import type { Registry } from "../registry/index.js";
|
|
3
|
+
|
|
4
|
+
/** Inputs to a static name-resolution pass over a parsed document. */
|
|
5
|
+
export interface CheckArgs {
|
|
6
|
+
document: Document;
|
|
7
|
+
registry: Registry;
|
|
8
|
+
fragments: ReadonlySet<string>;
|
|
9
|
+
uri?: string;
|
|
10
|
+
/**
|
|
11
|
+
* The variables `venn.toml` declares. Omit it when they are unknown: an
|
|
12
|
+
* undeclared-variable error is only worth raising when the declaration list is
|
|
13
|
+
* trustworthy.
|
|
14
|
+
*/
|
|
15
|
+
env?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Everything a per-node check needs, resolved once per document. */
|
|
19
|
+
export interface CheckContext {
|
|
20
|
+
registry: Registry;
|
|
21
|
+
fragments: ReadonlySet<string>;
|
|
22
|
+
aliases: ReadonlyMap<string, string>;
|
|
23
|
+
/** Namespaces this file actually brought in with `use`. */
|
|
24
|
+
imported: ReadonlySet<string>;
|
|
25
|
+
/** Names this file binds: callable, but their methods cannot be verified. */
|
|
26
|
+
bound: ReadonlySet<string>;
|
|
27
|
+
/** Declared `env` variables, or undefined when the manifest could not be read. */
|
|
28
|
+
env?: ReadonlySet<string>;
|
|
29
|
+
uri: string;
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Host, Port } from "@venn-lang/contracts";
|
|
2
|
+
import { invoke } from "@venn-lang/core";
|
|
3
|
+
import type { ActionContext } from "@venn-lang/sdk";
|
|
4
|
+
import type { PortResolver } from "../ports/index.js";
|
|
5
|
+
|
|
6
|
+
/** Build the context an action's `run` receives from the host and port resolver. */
|
|
7
|
+
export function createActionContext(args: {
|
|
8
|
+
host: Host;
|
|
9
|
+
ports: PortResolver;
|
|
10
|
+
config?: Record<string, unknown>;
|
|
11
|
+
}): ActionContext {
|
|
12
|
+
return {
|
|
13
|
+
port: <T>(port: Port<T>): T => args.ports.resolve(port),
|
|
14
|
+
secrets: args.host.secrets,
|
|
15
|
+
config: args.config ?? {},
|
|
16
|
+
log: (message) => args.host.log.log({ level: "info", message }),
|
|
17
|
+
redact: () => {},
|
|
18
|
+
invoke: (fn, values) => invoke(fn, values),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createActionContext } from "./action-context.js";
|