@sdxc/spec 0.0.0-pre.1

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 (77) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +924 -0
  3. package/dist/ast.d.ts +193 -0
  4. package/dist/ast.js +9 -0
  5. package/dist/builtins.d.ts +29 -0
  6. package/dist/builtins.js +66 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.js +297 -0
  9. package/dist/diagnostics.d.ts +47 -0
  10. package/dist/diagnostics.js +8 -0
  11. package/dist/errors.d.ts +131 -0
  12. package/dist/errors.js +159 -0
  13. package/dist/executor.d.ts +66 -0
  14. package/dist/executor.js +320 -0
  15. package/dist/expectation.d.ts +61 -0
  16. package/dist/expectation.js +222 -0
  17. package/dist/index.d.ts +51 -0
  18. package/dist/index.js +36 -0
  19. package/dist/lexer.d.ts +22 -0
  20. package/dist/lexer.js +284 -0
  21. package/dist/loader.d.ts +21 -0
  22. package/dist/loader.js +81 -0
  23. package/dist/parser.d.ts +24 -0
  24. package/dist/parser.js +502 -0
  25. package/dist/permissions.d.ts +139 -0
  26. package/dist/permissions.js +325 -0
  27. package/dist/plugin.d.ts +90 -0
  28. package/dist/plugin.js +9 -0
  29. package/dist/plugins/browser.d.ts +24 -0
  30. package/dist/plugins/browser.js +896 -0
  31. package/dist/plugins/cli.d.ts +17 -0
  32. package/dist/plugins/cli.js +134 -0
  33. package/dist/plugins/db-e2e-probe.d.ts +14 -0
  34. package/dist/plugins/db-e2e-probe.js +112 -0
  35. package/dist/plugins/db.d.ts +19 -0
  36. package/dist/plugins/db.js +199 -0
  37. package/dist/plugins/demo.d.ts +17 -0
  38. package/dist/plugins/demo.js +70 -0
  39. package/dist/plugins/env.d.ts +18 -0
  40. package/dist/plugins/env.js +87 -0
  41. package/dist/plugins/fs.d.ts +16 -0
  42. package/dist/plugins/fs.js +415 -0
  43. package/dist/plugins/http.d.ts +19 -0
  44. package/dist/plugins/http.js +505 -0
  45. package/dist/plugins/jwt.d.ts +17 -0
  46. package/dist/plugins/jwt.js +342 -0
  47. package/dist/plugins/sample.d.ts +27 -0
  48. package/dist/plugins/sample.js +400 -0
  49. package/dist/plugins/url.d.ts +18 -0
  50. package/dist/plugins/url.js +126 -0
  51. package/dist/project-config.d.ts +163 -0
  52. package/dist/project-config.js +497 -0
  53. package/dist/registry.d.ts +56 -0
  54. package/dist/registry.js +110 -0
  55. package/dist/reporter.d.ts +30 -0
  56. package/dist/reporter.js +237 -0
  57. package/dist/run.d.ts +74 -0
  58. package/dist/run.js +179 -0
  59. package/dist/runner.d.ts +52 -0
  60. package/dist/runner.js +38 -0
  61. package/dist/source.d.ts +37 -0
  62. package/dist/source.js +31 -0
  63. package/dist/sources.d.ts +45 -0
  64. package/dist/sources.js +54 -0
  65. package/dist/tokens.d.ts +34 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/transport-stdio.d.ts +34 -0
  68. package/dist/transport-stdio.js +400 -0
  69. package/dist/values.d.ts +48 -0
  70. package/dist/values.js +52 -0
  71. package/dist/workers.d.ts +40 -0
  72. package/dist/workers.js +26 -0
  73. package/dist/workspace-none.d.ts +23 -0
  74. package/dist/workspace-none.js +33 -0
  75. package/dist/workspace.d.ts +47 -0
  76. package/dist/workspace.js +116 -0
  77. package/package.json +28 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The error taxonomy every fallible function in this package returns through
3
+ * `@sdxc/result`. Each class carries the structured fields diagnostics need —
4
+ * spans, expected/observed values, denial remedies — so the reporter formats
5
+ * errors without string-parsing them.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { PermissionKind } from "./permissions.js";
11
+ import type { Span } from "./source.js";
12
+ import type { Value } from "./values.js";
13
+ /**
14
+ * Machine-readable category of a failure, stable across message rewording.
15
+ * Reporters branch on this, never on message text.
16
+ */
17
+ export type DiagnosticCode = "parse-error" | "load-error" | "duplicate-definition" | "unknown-name" | "ambiguous-name" | "expectation-failed" | "permission-denied" | "tool-error" | "workspace-escape" | "usage-error";
18
+ /** Base class for every failure this package reports as a `Result` error. */
19
+ export declare class SpecError extends Error {
20
+ /** Stable failure category; see {@link DiagnosticCode}. */
21
+ code: DiagnosticCode;
22
+ /** Path of the `.spec` file involved, when the failure has one. */
23
+ file?: string;
24
+ /** Source range of the failing statement, when known. */
25
+ span?: Span;
26
+ /** An actionable suggestion, e.g. the exact `--allow-*` flag to add. */
27
+ remedy?: string;
28
+ /**
29
+ * An extra, situational line appended after the remedy — set by the CLI
30
+ * when it has extra context, e.g. that `spec/config.jsonc` would grant
31
+ * the permission. Never weakens the primary {@link remedy}.
32
+ */
33
+ hint?: string;
34
+ /**
35
+ * @param code - Stable failure category.
36
+ * @param message - Human-readable one-line description.
37
+ */
38
+ constructor(code: DiagnosticCode, message: string);
39
+ }
40
+ /** A lexical or syntactic failure while reading a `.spec` file. */
41
+ export declare class ParseError extends SpecError {
42
+ /**
43
+ * @param message - What the parser expected and what it found.
44
+ * @param file - Path of the file being parsed.
45
+ * @param span - Range of the offending text.
46
+ */
47
+ constructor(message: string, file?: string, span?: Span);
48
+ }
49
+ /**
50
+ * A suite-level failure before any test runs: unreadable directories,
51
+ * duplicate definitions, or a file that failed to parse during loading.
52
+ */
53
+ export declare class LoadError extends SpecError {
54
+ /**
55
+ * @param code - `"load-error"` or `"duplicate-definition"`.
56
+ * @param message - What prevented the suite from loading.
57
+ */
58
+ constructor(code: DiagnosticCode, message: string);
59
+ }
60
+ /**
61
+ * A name that resolved to nothing (`unknown-name`) or to more than one
62
+ * candidate (`ambiguous-name`). The runtime never guesses; it reports the
63
+ * candidates and asks for a qualified name.
64
+ */
65
+ export declare class ResolutionError extends SpecError {
66
+ /** Fully qualified candidates, populated for ambiguity errors. */
67
+ candidates: string[];
68
+ /**
69
+ * @param code - `"unknown-name"` or `"ambiguous-name"`.
70
+ * @param message - The name and, when ambiguous, its candidates.
71
+ * @param candidates - Fully qualified candidates for ambiguous names.
72
+ */
73
+ constructor(code: DiagnosticCode, message: string, candidates?: string[]);
74
+ }
75
+ /** An `expect` that did not hold, carrying both sides for the reporter. */
76
+ export declare class ExpectationError extends SpecError {
77
+ /** The value the specification demanded, when the form has one. */
78
+ expected?: Value;
79
+ /** The value actually observed. */
80
+ observed?: Value;
81
+ /**
82
+ * @param message - One-line statement of the failed expectation.
83
+ * @param expected - The demanded value, when applicable.
84
+ * @param observed - The observed value, when applicable.
85
+ */
86
+ constructor(message: string, expected?: Value, observed?: Value);
87
+ }
88
+ /**
89
+ * A capability use the caller never granted. Always names the permission,
90
+ * the attempted resource, and the exact flag that would grant it — the
91
+ * design suite makes this diagnostic quality a requirement, not a nicety.
92
+ */
93
+ export declare class PermissionDeniedError extends SpecError {
94
+ /** Which permission family was required. */
95
+ permission: PermissionKind;
96
+ /** What the spec attempted to reach: an executable, host, variable, path. */
97
+ resource: string;
98
+ /**
99
+ * Whether the coarse family gate raised this denial before the resource
100
+ * was known, in which case {@link resource} holds the qualified tool name.
101
+ * Governs whether `--allow-config` needs family- or scope-level coverage.
102
+ */
103
+ familyGate: boolean;
104
+ /**
105
+ * @param permission - The required permission family.
106
+ * @param resource - The attempted resource.
107
+ * @param remedy - The exact `spec run` flag that would grant it.
108
+ * @param familyGate - Whether the coarse family gate raised this denial.
109
+ */
110
+ constructor(permission: PermissionKind, resource: string, remedy: string, familyGate?: boolean);
111
+ }
112
+ /** A tool that was reached and ran, but failed on its own terms. */
113
+ export declare class ToolError extends SpecError {
114
+ /**
115
+ * @param message - The tool's own account of the failure.
116
+ */
117
+ constructor(message: string);
118
+ }
119
+ /**
120
+ * A path that would leave the isolated workspace without a host-filesystem
121
+ * grant — reported distinctly from permission denials so traversal attempts
122
+ * are visible as what they are.
123
+ */
124
+ export declare class WorkspaceEscapeError extends SpecError {
125
+ /** The offending path as written in the spec. */
126
+ attemptedPath: string;
127
+ /**
128
+ * @param attemptedPath - The path as the spec wrote it.
129
+ */
130
+ constructor(attemptedPath: string);
131
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The error taxonomy every fallible function in this package returns through
3
+ * `@sdxc/result`. Each class carries the structured fields diagnostics need —
4
+ * spans, expected/observed values, denial remedies — so the reporter formats
5
+ * errors without string-parsing them.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ /** Base class for every failure this package reports as a `Result` error. */
11
+ export class SpecError extends Error {
12
+ /** Stable failure category; see {@link DiagnosticCode}. */
13
+ code;
14
+ /** Path of the `.spec` file involved, when the failure has one. */
15
+ file;
16
+ /** Source range of the failing statement, when known. */
17
+ span;
18
+ /** An actionable suggestion, e.g. the exact `--allow-*` flag to add. */
19
+ remedy;
20
+ /**
21
+ * An extra, situational line appended after the remedy — set by the CLI
22
+ * when it has extra context, e.g. that `spec/config.jsonc` would grant
23
+ * the permission. Never weakens the primary {@link remedy}.
24
+ */
25
+ hint;
26
+ /**
27
+ * @param code - Stable failure category.
28
+ * @param message - Human-readable one-line description.
29
+ */
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = "SpecError";
33
+ this.code = code;
34
+ }
35
+ }
36
+ /** A lexical or syntactic failure while reading a `.spec` file. */
37
+ export class ParseError extends SpecError {
38
+ /**
39
+ * @param message - What the parser expected and what it found.
40
+ * @param file - Path of the file being parsed.
41
+ * @param span - Range of the offending text.
42
+ */
43
+ constructor(message, file, span) {
44
+ super("parse-error", message);
45
+ this.name = "ParseError";
46
+ this.file = file;
47
+ this.span = span;
48
+ }
49
+ }
50
+ /**
51
+ * A suite-level failure before any test runs: unreadable directories,
52
+ * duplicate definitions, or a file that failed to parse during loading.
53
+ */
54
+ export class LoadError extends SpecError {
55
+ /**
56
+ * @param code - `"load-error"` or `"duplicate-definition"`.
57
+ * @param message - What prevented the suite from loading.
58
+ */
59
+ constructor(code, message) {
60
+ super(code, message);
61
+ this.name = "LoadError";
62
+ }
63
+ }
64
+ /**
65
+ * A name that resolved to nothing (`unknown-name`) or to more than one
66
+ * candidate (`ambiguous-name`). The runtime never guesses; it reports the
67
+ * candidates and asks for a qualified name.
68
+ */
69
+ export class ResolutionError extends SpecError {
70
+ /** Fully qualified candidates, populated for ambiguity errors. */
71
+ candidates;
72
+ /**
73
+ * @param code - `"unknown-name"` or `"ambiguous-name"`.
74
+ * @param message - The name and, when ambiguous, its candidates.
75
+ * @param candidates - Fully qualified candidates for ambiguous names.
76
+ */
77
+ constructor(code, message, candidates = []) {
78
+ super(code, message);
79
+ this.name = "ResolutionError";
80
+ this.candidates = candidates;
81
+ }
82
+ }
83
+ /** An `expect` that did not hold, carrying both sides for the reporter. */
84
+ export class ExpectationError extends SpecError {
85
+ /** The value the specification demanded, when the form has one. */
86
+ expected;
87
+ /** The value actually observed. */
88
+ observed;
89
+ /**
90
+ * @param message - One-line statement of the failed expectation.
91
+ * @param expected - The demanded value, when applicable.
92
+ * @param observed - The observed value, when applicable.
93
+ */
94
+ constructor(message, expected, observed) {
95
+ super("expectation-failed", message);
96
+ this.name = "ExpectationError";
97
+ this.expected = expected;
98
+ this.observed = observed;
99
+ }
100
+ }
101
+ /**
102
+ * A capability use the caller never granted. Always names the permission,
103
+ * the attempted resource, and the exact flag that would grant it — the
104
+ * design suite makes this diagnostic quality a requirement, not a nicety.
105
+ */
106
+ export class PermissionDeniedError extends SpecError {
107
+ /** Which permission family was required. */
108
+ permission;
109
+ /** What the spec attempted to reach: an executable, host, variable, path. */
110
+ resource;
111
+ /**
112
+ * Whether the coarse family gate raised this denial before the resource
113
+ * was known, in which case {@link resource} holds the qualified tool name.
114
+ * Governs whether `--allow-config` needs family- or scope-level coverage.
115
+ */
116
+ familyGate;
117
+ /**
118
+ * @param permission - The required permission family.
119
+ * @param resource - The attempted resource.
120
+ * @param remedy - The exact `spec run` flag that would grant it.
121
+ * @param familyGate - Whether the coarse family gate raised this denial.
122
+ */
123
+ constructor(permission, resource, remedy, familyGate = false) {
124
+ super("permission-denied", `Permission denied: ${permission}. The spec attempted to reach: ${resource}`);
125
+ this.name = "PermissionDeniedError";
126
+ this.permission = permission;
127
+ this.resource = resource;
128
+ this.remedy = remedy;
129
+ this.familyGate = familyGate;
130
+ }
131
+ }
132
+ /** A tool that was reached and ran, but failed on its own terms. */
133
+ export class ToolError extends SpecError {
134
+ /**
135
+ * @param message - The tool's own account of the failure.
136
+ */
137
+ constructor(message) {
138
+ super("tool-error", message);
139
+ this.name = "ToolError";
140
+ }
141
+ }
142
+ /**
143
+ * A path that would leave the isolated workspace without a host-filesystem
144
+ * grant — reported distinctly from permission denials so traversal attempts
145
+ * are visible as what they are.
146
+ */
147
+ export class WorkspaceEscapeError extends SpecError {
148
+ /** The offending path as written in the spec. */
149
+ attemptedPath;
150
+ /**
151
+ * @param attemptedPath - The path as the spec wrote it.
152
+ */
153
+ constructor(attemptedPath) {
154
+ super("workspace-escape", `Path resolves outside the test workspace: ${attemptedPath}`);
155
+ this.name = "WorkspaceEscapeError";
156
+ this.attemptedPath = attemptedPath;
157
+ this.remedy = "spec run --allow-host-fs=<directory>";
158
+ }
159
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The interpreter core of the runtime: executes one test's statements against
3
+ * the suite registry, an isolated workspace, and the caller's grants. Owns
4
+ * scopes, `let`/`return`, command and fixture invocation, and the central
5
+ * permission gate that refuses denied permission families before a plugin
6
+ * ever sees the call.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Result } from "@sdxc/result";
12
+ import type { Random } from "@sdxc/sample";
13
+ import type { DefinitionNode, TestNode } from "./ast.js";
14
+ import type { Grants, PermissionSet } from "./permissions.js";
15
+ import type { Registry } from "./registry.js";
16
+ import type { Workspace } from "./workspace.js";
17
+ import { SpecError } from "./errors.js";
18
+ /**
19
+ * Everything one test needs to execute: the suite's resolution table, its
20
+ * isolated workspace, the caller's grants, and the namespaces its file
21
+ * imported with `use`.
22
+ */
23
+ export interface ExecutionContext {
24
+ /** The suite's name-resolution table. */
25
+ registry: Registry;
26
+ /** The test's isolated workspace, handed to every tool call. */
27
+ workspace: Workspace;
28
+ /** The caller's grant set, handed to every tool call for scoped checks. */
29
+ permissions: PermissionSet;
30
+ /** The test's seeded stream, handed to every tool call that generates data. */
31
+ random: Random;
32
+ /** The instant the test started, frozen for the whole test. */
33
+ now: Date;
34
+ /** Namespaces imported by the test's file, in `use` order. */
35
+ uses: readonly string[];
36
+ /**
37
+ * The namespaces imported by the file that DEFINED a command or fixture —
38
+ * `use` is file-scoped, so a definition's body resolves bare names against
39
+ * its own file's imports, never the caller's.
40
+ */
41
+ usesFor: (definition: DefinitionNode) => readonly string[];
42
+ /**
43
+ * The path of the file that DEFINED a command or fixture. Errors inside a
44
+ * definition's body anchor to the defining file so their spans map onto
45
+ * the source text they came from; when absent, errors keep the calling file.
46
+ */
47
+ fileFor?: (definition: DefinitionNode) => string | undefined;
48
+ /**
49
+ * The parsed grant modes: the executor refuses a denied permission family
50
+ * before its plugin runs, and scoped refinement (host, binary) happens
51
+ * inside the plugin through the runtime-owned `PermissionSet`.
52
+ */
53
+ grants: Grants;
54
+ /** Path of the file the test lives in, stamped onto every error. */
55
+ file?: string;
56
+ }
57
+ /**
58
+ * Execute one test: its `given`, `when`, and `then` phases share a single
59
+ * scope and run in order, and the first failing statement ends the test.
60
+ * Every error is stamped with the failing statement's span and file path.
61
+ *
62
+ * @param test - The test to execute.
63
+ * @param context - The suite services and grants the test runs against.
64
+ * @returns Success when every statement held, otherwise the first failure.
65
+ */
66
+ export declare function executeTest(test: TestNode, context: ExecutionContext): Promise<Result<undefined, SpecError>>;
@@ -0,0 +1,320 @@
1
+ /**
2
+ * The interpreter core of the runtime: executes one test's statements against
3
+ * the suite registry, an isolated workspace, and the caller's grants. Owns
4
+ * scopes, `let`/`return`, command and fixture invocation, and the central
5
+ * permission gate that refuses denied permission families before a plugin
6
+ * ever sees the call.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { failure, isFailure, success } from "@sdxc/result";
12
+ import { PermissionDeniedError, ResolutionError, SpecError, ToolError } from "./errors.js";
13
+ import { executeEventually, executeExpect } from "./expectation.js";
14
+ /** How deep command/fixture invocations may nest before a cycle is suspected. */
15
+ const MAX_CALL_DEPTH = 32;
16
+ /** Maps a permission family to its key in the {@link Grants} record. */
17
+ const GRANT_KEYS = {
18
+ run: "run",
19
+ net: "net",
20
+ env: "env",
21
+ "host-fs": "hostFs",
22
+ };
23
+ /**
24
+ * Execute one test: its `given`, `when`, and `then` phases share a single
25
+ * scope and run in order, and the first failing statement ends the test.
26
+ * Every error is stamped with the failing statement's span and file path.
27
+ *
28
+ * @param test - The test to execute.
29
+ * @param context - The suite services and grants the test runs against.
30
+ * @returns Success when every statement held, otherwise the first failure.
31
+ */
32
+ export async function executeTest(test, context) {
33
+ let scope = new Map();
34
+ let environment = { ...context, depth: 0 };
35
+ let phases = [test.given, test.when, test.then];
36
+ for (let phase of phases) {
37
+ if (phase === undefined)
38
+ continue;
39
+ let outcome = await executeStatements(phase.statements, scope, environment, false);
40
+ if (isFailure(outcome))
41
+ return outcome;
42
+ }
43
+ return success(undefined);
44
+ }
45
+ /**
46
+ * Run a statement sequence in order, anchoring every failure to the failing
47
+ * statement, until it completes, returns, or fails.
48
+ */
49
+ async function executeStatements(statements, scope, environment, allowReturn) {
50
+ for (let statement of statements) {
51
+ let result = await executeStatement(statement, scope, environment, allowReturn);
52
+ if (isFailure(result)) {
53
+ return failure(anchor(result.error, statement.span, environment.file));
54
+ }
55
+ if (result.data.kind === "returned")
56
+ return result;
57
+ }
58
+ return success({ kind: "completed" });
59
+ }
60
+ /** Dispatch one statement: let, return, call, expect, or eventually. */
61
+ async function executeStatement(statement, scope, environment, allowReturn) {
62
+ if (statement.kind === "let") {
63
+ if (scope.has(statement.name)) {
64
+ return failure(new SpecError("usage-error", `"${statement.name}" is already bound; let never rebinds a name`));
65
+ }
66
+ let value = await evaluateRhs(statement.value, scope, environment);
67
+ if (isFailure(value))
68
+ return value;
69
+ scope.set(statement.name, value.data);
70
+ return success({ kind: "completed" });
71
+ }
72
+ if (statement.kind === "return") {
73
+ if (!allowReturn) {
74
+ return failure(new SpecError("usage-error", "return is only valid inside command and fixture bodies"));
75
+ }
76
+ let value = await evaluateRhs(statement.value, scope, environment);
77
+ if (isFailure(value))
78
+ return value;
79
+ return success({ kind: "returned", value: value.data });
80
+ }
81
+ if (statement.kind === "call") {
82
+ let result = await invokeCallable(statement.target, statement.args, statement.span, scope, environment);
83
+ if (isFailure(result))
84
+ return result;
85
+ return success({ kind: "completed" });
86
+ }
87
+ if (statement.kind === "expect") {
88
+ let result = await executeExpect(statement, makeHost(scope, environment));
89
+ if (isFailure(result))
90
+ return result;
91
+ return success({ kind: "completed" });
92
+ }
93
+ let result = await executeEventually(statement, makeHost(scope, environment));
94
+ if (isFailure(result))
95
+ return result;
96
+ return success({ kind: "completed" });
97
+ }
98
+ /** Evaluate a `let`/`return` right-hand side: expression, fixture, or call. */
99
+ async function evaluateRhs(rhs, scope, environment) {
100
+ if (rhs.kind === "fixture-call")
101
+ return runFixture(rhs.name, rhs.span, environment);
102
+ if (rhs.kind === "call-expr") {
103
+ return invokeCallable(rhs.target, rhs.args, rhs.span, scope, environment);
104
+ }
105
+ if (rhs.kind === "reference") {
106
+ let call = zeroArgToolCall(rhs, scope, environment);
107
+ if (call !== undefined)
108
+ return call;
109
+ }
110
+ return evaluateExpression(rhs, scope);
111
+ }
112
+ /**
113
+ * A bare-path `let`/`return` right-hand side normally references the scope;
114
+ * an unbound head may instead name a zero-argument tool, dispatched through
115
+ * the ordinary tool path so the runtime's permission gate still applies.
116
+ */
117
+ function zeroArgToolCall(reference, scope, environment) {
118
+ let head = reference.path[0];
119
+ if (head === undefined || scope.has(head))
120
+ return undefined;
121
+ let resolved = environment.registry.resolveCallable(reference.path.join("."), environment.uses);
122
+ if (isFailure(resolved) || resolved.data.kind !== "tool")
123
+ return undefined;
124
+ if (resolved.data.descriptor.params.some((param) => param.required))
125
+ return undefined;
126
+ return invokeTool(resolved.data, [], reference.span, scope, environment);
127
+ }
128
+ function evaluateExpression(expression, scope) {
129
+ if (expression.kind === "string")
130
+ return success(expression.value);
131
+ if (expression.kind === "number")
132
+ return success(expression.value);
133
+ if (expression.kind === "boolean")
134
+ return success(expression.value);
135
+ if (expression.kind === "duration")
136
+ return success(expression.milliseconds);
137
+ if (expression.kind === "object") {
138
+ let object = {};
139
+ for (let entry of expression.entries) {
140
+ let value = evaluateExpression(entry.value, scope);
141
+ if (isFailure(value))
142
+ return value;
143
+ object[entry.key] = value.data;
144
+ }
145
+ return success(object);
146
+ }
147
+ return resolveReference(expression, scope);
148
+ }
149
+ /**
150
+ * Resolve a dotted reference: the head segment must be a binding and every
151
+ * further segment a field of the value so far — a miss is an `unknown-name`
152
+ * error, never `null`.
153
+ */
154
+ function resolveReference(reference, scope) {
155
+ let head = reference.path[0];
156
+ if (head === undefined || !scope.has(head)) {
157
+ return failure(anchor(new ResolutionError("unknown-name", `Unknown name "${head ?? ""}" — nothing is bound under it`), reference.span));
158
+ }
159
+ let current = scope.get(head) ?? null;
160
+ for (let index = 1; index < reference.path.length; index++) {
161
+ let segment = reference.path[index];
162
+ if (segment === undefined)
163
+ continue;
164
+ if (typeof current !== "object" ||
165
+ current === null ||
166
+ Array.isArray(current) ||
167
+ !(segment in current)) {
168
+ let prefix = reference.path.slice(0, index).join(".");
169
+ return failure(anchor(new ResolutionError("unknown-name", `Unknown field "${segment}" — "${prefix}" has no such field`), reference.span));
170
+ }
171
+ current = current[segment] ?? null;
172
+ }
173
+ return success(current);
174
+ }
175
+ /** Resolve a call target and invoke the tool or command it names. */
176
+ async function invokeCallable(target, args, span, scope, environment) {
177
+ let resolved = environment.registry.resolveCallable(target, environment.uses);
178
+ if (isFailure(resolved))
179
+ return failure(anchor(resolved.error, span, environment.file));
180
+ if (resolved.data.kind === "tool") {
181
+ return invokeTool(resolved.data, args, span, scope, environment);
182
+ }
183
+ return invokeCommand(resolved.data.command, args, span, scope, environment);
184
+ }
185
+ /**
186
+ * Invoke one plugin tool: evaluate the arguments (words stay symbolic), pass
187
+ * the central permission gate, then hand the call to the plugin with the
188
+ * test's workspace and grants.
189
+ */
190
+ async function invokeTool(tool, args, span, scope, environment) {
191
+ let toolArgs = [];
192
+ for (let argument of args) {
193
+ if (argument.kind === "word") {
194
+ toolArgs.push({ kind: "word", word: argument.word });
195
+ continue;
196
+ }
197
+ let value = evaluateExpression(argument, scope);
198
+ if (isFailure(value))
199
+ return value;
200
+ toolArgs.push({ kind: "value", value: value.data });
201
+ }
202
+ let gate = gateToolCall(tool, environment);
203
+ if (isFailure(gate))
204
+ return failure(anchor(gate.error, span, environment.file));
205
+ let result = await tool.plugin.call(tool.descriptor.name, toolArgs, {
206
+ workspace: environment.workspace,
207
+ permissions: environment.permissions,
208
+ random: environment.random,
209
+ now: environment.now,
210
+ });
211
+ if (isFailure(result))
212
+ return failure(anchor(result.error, span, environment.file));
213
+ return result;
214
+ }
215
+ /**
216
+ * The runtime's coarse permission gate: a tool whose required permission
217
+ * family is denied outright never reaches its plugin; scoped refinement
218
+ * happens inside the plugin through the runtime-owned `PermissionSet`.
219
+ */
220
+ function gateToolCall(tool, environment) {
221
+ let required = tool.descriptor.requires;
222
+ if (required === undefined)
223
+ return success(undefined);
224
+ let grant = environment.grants[GRANT_KEYS[required]];
225
+ if (grant.mode !== "denied")
226
+ return success(undefined);
227
+ let qualified = `${tool.namespace}.${tool.descriptor.name}`;
228
+ return failure(new PermissionDeniedError(required, qualified, `spec run --allow-${required}`, true));
229
+ }
230
+ /**
231
+ * Invoke one suite command: arguments are evaluated as values (a bare word
232
+ * reads the caller's binding of that spelling) and bound positionally to a
233
+ * fresh scope; the body's `return` value (or `null`) is the call's value.
234
+ */
235
+ async function invokeCommand(command, args, span, scope, environment) {
236
+ let values = [];
237
+ for (let argument of args) {
238
+ let value = evaluateValueArgument(argument, scope);
239
+ if (isFailure(value))
240
+ return value;
241
+ values.push(value.data);
242
+ }
243
+ if (values.length !== command.params.length) {
244
+ return failure(anchor(new SpecError("usage-error", `Command "${command.name}" expects ${command.params.length} argument(s), got ${values.length}`), span, environment.file));
245
+ }
246
+ let commandScope = new Map();
247
+ for (let index = 0; index < command.params.length; index++) {
248
+ let param = command.params[index];
249
+ if (param === undefined)
250
+ continue;
251
+ commandScope.set(param, values[index] ?? null);
252
+ }
253
+ return runBody(command, commandScope, span, environment);
254
+ }
255
+ /** Run `fixture NAME`: a fresh, empty scope; the body runs on every call. */
256
+ async function runFixture(name, span, environment) {
257
+ let resolved = environment.registry.resolveFixture(name);
258
+ if (isFailure(resolved))
259
+ return failure(anchor(resolved.error, span, environment.file));
260
+ return runBody(resolved.data, new Map(), span, environment);
261
+ }
262
+ /**
263
+ * Run a command or fixture body under the recursion cap; a body that never
264
+ * returns produces `null`. Because `use` is file-scoped, the body resolves
265
+ * bare names against the defining file's imports, so its errors anchor there.
266
+ */
267
+ async function runBody(definition, scope, span, environment) {
268
+ if (environment.depth >= MAX_CALL_DEPTH) {
269
+ return failure(anchor(new ToolError(`Call depth exceeded ${MAX_CALL_DEPTH} while invoking ${definition.kind} "${definition.name}" — a command or fixture cycle is suspected`), span, environment.file));
270
+ }
271
+ let nested = {
272
+ ...environment,
273
+ depth: environment.depth + 1,
274
+ uses: environment.usesFor(definition),
275
+ };
276
+ let definitionFile = environment.fileFor?.(definition);
277
+ if (definitionFile !== undefined)
278
+ nested.file = definitionFile;
279
+ let outcome = await executeStatements(definition.body.statements, scope, nested, true);
280
+ if (isFailure(outcome))
281
+ return outcome;
282
+ if (outcome.data.kind === "returned")
283
+ return success(outcome.data.value);
284
+ return success(null);
285
+ }
286
+ /**
287
+ * Evaluate one argument as a value: expressions evaluate in the scope, and a
288
+ * bare word reads the binding of the same spelling — words are only symbolic
289
+ * when a tool receives them.
290
+ */
291
+ function evaluateValueArgument(argument, scope) {
292
+ if (argument.kind !== "word")
293
+ return evaluateExpression(argument, scope);
294
+ if (!scope.has(argument.word)) {
295
+ return failure(anchor(new ResolutionError("unknown-name", `Unknown name "${argument.word}" — nothing is bound under it`), argument.span));
296
+ }
297
+ return success(scope.get(argument.word) ?? null);
298
+ }
299
+ /** The seam `expect`/`eventually` use to evaluate and dispatch through us. */
300
+ function makeHost(scope, environment) {
301
+ return {
302
+ scope,
303
+ registry: environment.registry,
304
+ uses: environment.uses,
305
+ evaluate(expression) {
306
+ return evaluateExpression(expression, scope);
307
+ },
308
+ callTool(tool, args, span) {
309
+ return invokeTool(tool, args, span, scope, environment);
310
+ },
311
+ };
312
+ }
313
+ /** Stamp a span and file onto an error that does not carry them yet. */
314
+ function anchor(error, span, file) {
315
+ if (error.span === undefined)
316
+ error.span = span;
317
+ if (error.file === undefined && file !== undefined)
318
+ error.file = file;
319
+ return error;
320
+ }