@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.
- package/LICENSE.md +21 -0
- package/README.md +924 -0
- package/dist/ast.d.ts +193 -0
- package/dist/ast.js +9 -0
- package/dist/builtins.d.ts +29 -0
- package/dist/builtins.js +66 -0
- package/dist/cli.d.ts +21 -0
- package/dist/cli.js +297 -0
- package/dist/diagnostics.d.ts +47 -0
- package/dist/diagnostics.js +8 -0
- package/dist/errors.d.ts +131 -0
- package/dist/errors.js +159 -0
- package/dist/executor.d.ts +66 -0
- package/dist/executor.js +320 -0
- package/dist/expectation.d.ts +61 -0
- package/dist/expectation.js +222 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +36 -0
- package/dist/lexer.d.ts +22 -0
- package/dist/lexer.js +284 -0
- package/dist/loader.d.ts +21 -0
- package/dist/loader.js +81 -0
- package/dist/parser.d.ts +24 -0
- package/dist/parser.js +502 -0
- package/dist/permissions.d.ts +139 -0
- package/dist/permissions.js +325 -0
- package/dist/plugin.d.ts +90 -0
- package/dist/plugin.js +9 -0
- package/dist/plugins/browser.d.ts +24 -0
- package/dist/plugins/browser.js +896 -0
- package/dist/plugins/cli.d.ts +17 -0
- package/dist/plugins/cli.js +134 -0
- package/dist/plugins/db-e2e-probe.d.ts +14 -0
- package/dist/plugins/db-e2e-probe.js +112 -0
- package/dist/plugins/db.d.ts +19 -0
- package/dist/plugins/db.js +199 -0
- package/dist/plugins/demo.d.ts +17 -0
- package/dist/plugins/demo.js +70 -0
- package/dist/plugins/env.d.ts +18 -0
- package/dist/plugins/env.js +87 -0
- package/dist/plugins/fs.d.ts +16 -0
- package/dist/plugins/fs.js +415 -0
- package/dist/plugins/http.d.ts +19 -0
- package/dist/plugins/http.js +505 -0
- package/dist/plugins/jwt.d.ts +17 -0
- package/dist/plugins/jwt.js +342 -0
- package/dist/plugins/sample.d.ts +27 -0
- package/dist/plugins/sample.js +400 -0
- package/dist/plugins/url.d.ts +18 -0
- package/dist/plugins/url.js +126 -0
- package/dist/project-config.d.ts +163 -0
- package/dist/project-config.js +497 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +110 -0
- package/dist/reporter.d.ts +30 -0
- package/dist/reporter.js +237 -0
- package/dist/run.d.ts +74 -0
- package/dist/run.js +179 -0
- package/dist/runner.d.ts +52 -0
- package/dist/runner.js +38 -0
- package/dist/source.d.ts +37 -0
- package/dist/source.js +31 -0
- package/dist/sources.d.ts +45 -0
- package/dist/sources.js +54 -0
- package/dist/tokens.d.ts +34 -0
- package/dist/tokens.js +25 -0
- package/dist/transport-stdio.d.ts +34 -0
- package/dist/transport-stdio.js +400 -0
- package/dist/values.d.ts +48 -0
- package/dist/values.js +52 -0
- package/dist/workers.d.ts +40 -0
- package/dist/workers.js +26 -0
- package/dist/workspace-none.d.ts +23 -0
- package/dist/workspace-none.js +33 -0
- package/dist/workspace.d.ts +47 -0
- package/dist/workspace.js +116 -0
- package/package.json +28 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The NDJSON-over-stdio plugin transport: how an external executable becomes
|
|
3
|
+
* a `Plugin`, one JSON document per line over the child's stdio, with
|
|
4
|
+
* strictly increasing request ids and in-order replies. The child inherits
|
|
5
|
+
* no environment beyond PATH.
|
|
6
|
+
*
|
|
7
|
+
* `workspaceRoot` crosses the wire so a plugin can resolve its own paths, but
|
|
8
|
+
* scoped permission enforcement over the wire is still an open design
|
|
9
|
+
* question — the host's coarse `requires` gate runs before every call.
|
|
10
|
+
*
|
|
11
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
12
|
+
* @copyright Sergio Xalambrí 2026
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { failure, isFailure, isSuccess, success } from "@sdxc/result";
|
|
17
|
+
import { createRandom } from "@sdxc/sample";
|
|
18
|
+
import { PermissionDeniedError, SpecError, ToolError, WorkspaceEscapeError } from "./errors.js";
|
|
19
|
+
/** How long `connectStdioPlugin` waits for the describe reply. */
|
|
20
|
+
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
21
|
+
/** Every code a plugin may put on the wire; anything else maps to tool-error. */
|
|
22
|
+
const WIRE_CODES = new Set([
|
|
23
|
+
"parse-error",
|
|
24
|
+
"load-error",
|
|
25
|
+
"duplicate-definition",
|
|
26
|
+
"unknown-name",
|
|
27
|
+
"ambiguous-name",
|
|
28
|
+
"expectation-failed",
|
|
29
|
+
"permission-denied",
|
|
30
|
+
"tool-error",
|
|
31
|
+
"workspace-escape",
|
|
32
|
+
"usage-error",
|
|
33
|
+
]);
|
|
34
|
+
/** The seed a served plugin's stream opens on. */
|
|
35
|
+
const WIRE_SEED = "spec-plugin";
|
|
36
|
+
/**
|
|
37
|
+
* Spawn an external plugin process and connect it as a `Plugin`: send the
|
|
38
|
+
* describe handshake (5s timeout), cache the returned descriptors, and hand
|
|
39
|
+
* back a `call()` that round-trips over the child's stdio.
|
|
40
|
+
*
|
|
41
|
+
* @param command - The argv to spawn, e.g. `["bun", "plugins/demo.ts"]`.
|
|
42
|
+
* @param namespace - The namespace the connected plugin's tools live under.
|
|
43
|
+
* @returns The connected plugin, or the failure that prevented the handshake.
|
|
44
|
+
*/
|
|
45
|
+
export async function connectStdioPlugin(command, namespace) {
|
|
46
|
+
if (command.length === 0) {
|
|
47
|
+
return failure(new ToolError("Cannot connect a stdio plugin: the command is empty"));
|
|
48
|
+
}
|
|
49
|
+
let child;
|
|
50
|
+
try {
|
|
51
|
+
child = await spawnChild(command);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
return failure(new ToolError(`Failed to spawn plugin command "${command.join(" ")}": ${errorMessage(error)}`));
|
|
55
|
+
}
|
|
56
|
+
let connection = openConnection(child);
|
|
57
|
+
let handshake = await connection.request({ method: "describe" }, HANDSHAKE_TIMEOUT_MS);
|
|
58
|
+
if (isFailure(handshake)) {
|
|
59
|
+
connection.kill();
|
|
60
|
+
return handshake;
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(handshake.data)) {
|
|
63
|
+
connection.kill();
|
|
64
|
+
return failure(new ToolError(`Plugin "${command.join(" ")}" answered the describe handshake with ${JSON.stringify(handshake.data)} instead of a tool descriptor list`));
|
|
65
|
+
}
|
|
66
|
+
let descriptors = handshake.data;
|
|
67
|
+
return success({
|
|
68
|
+
namespace,
|
|
69
|
+
describe() {
|
|
70
|
+
return descriptors;
|
|
71
|
+
},
|
|
72
|
+
async call(tool, args, context) {
|
|
73
|
+
let reply = await connection.request({
|
|
74
|
+
method: "call",
|
|
75
|
+
tool,
|
|
76
|
+
args,
|
|
77
|
+
workspaceRoot: context.workspace.root,
|
|
78
|
+
now: context.now.toISOString(),
|
|
79
|
+
});
|
|
80
|
+
if (isFailure(reply))
|
|
81
|
+
return reply;
|
|
82
|
+
return success(reply.data);
|
|
83
|
+
},
|
|
84
|
+
/**
|
|
85
|
+
* Kills the child and fails any in-flight request. The runner calls this
|
|
86
|
+
* once after the suite, so the launched process never lingers.
|
|
87
|
+
*/
|
|
88
|
+
async dispose() {
|
|
89
|
+
connection.kill();
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The plugin-side serve loop: read requests from stdin, dispatch each to the
|
|
95
|
+
* given plugin, and write matching replies to stdout, in order. A line that
|
|
96
|
+
* fails to parse carries no id, so parsing simply continues to the next one.
|
|
97
|
+
*
|
|
98
|
+
* @param plugin - The local plugin implementation to expose over the wire.
|
|
99
|
+
*/
|
|
100
|
+
export async function servePlugin(plugin) {
|
|
101
|
+
for await (let line of readLines(process.stdin)) {
|
|
102
|
+
if (line.trim() === "")
|
|
103
|
+
continue;
|
|
104
|
+
let request = parseWireRequest(line);
|
|
105
|
+
if (request === null)
|
|
106
|
+
continue;
|
|
107
|
+
if (request.method === "describe") {
|
|
108
|
+
writeReply({ id: request.id, result: plugin.describe() });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (request.method === "call") {
|
|
112
|
+
let outcome = await plugin.call(request.tool ?? "", request.args ?? [], createWireContext(request.workspaceRoot ?? "", request.now));
|
|
113
|
+
if (isSuccess(outcome)) {
|
|
114
|
+
writeReply({ id: request.id, result: outcome.data });
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
writeReply({
|
|
118
|
+
id: request.id,
|
|
119
|
+
error: { code: outcome.error.code, message: outcome.error.message },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
writeReply({
|
|
125
|
+
id: request.id,
|
|
126
|
+
error: { code: "usage-error", message: `Unknown method "${request.method}"` },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Spawn a plugin command, resolving only once the OS confirms it started, so
|
|
133
|
+
* a bad executable fails here instead of later. It inherits nothing but
|
|
134
|
+
* PATH; a mid-call exit's broken stdin pipe surfaces via the pending request.
|
|
135
|
+
*
|
|
136
|
+
* @param command - The argv to spawn, first element being the executable.
|
|
137
|
+
* @returns The started child, narrowed to what the transport reads and writes.
|
|
138
|
+
* @throws When the executable cannot be started.
|
|
139
|
+
*/
|
|
140
|
+
async function spawnChild(command) {
|
|
141
|
+
let child = spawn(command[0] ?? "", command.slice(1), {
|
|
142
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
143
|
+
env: { PATH: process.env.PATH ?? "" },
|
|
144
|
+
});
|
|
145
|
+
child.stdin?.on("error", () => { });
|
|
146
|
+
let failed = await new Promise((settle) => {
|
|
147
|
+
child.once("spawn", () => settle(undefined));
|
|
148
|
+
child.once("error", (error) => settle(error));
|
|
149
|
+
});
|
|
150
|
+
if (failed !== undefined)
|
|
151
|
+
throw failed;
|
|
152
|
+
let stdin = child.stdin;
|
|
153
|
+
let stdout = child.stdout;
|
|
154
|
+
if (stdin === null || stdout === null) {
|
|
155
|
+
child.kill();
|
|
156
|
+
throw new Error("the child was started without usable stdio pipes");
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
stdin,
|
|
160
|
+
stdout,
|
|
161
|
+
kill() {
|
|
162
|
+
child.kill();
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/** Wire one spawned child into a request/reply connection. */
|
|
167
|
+
function openConnection(child) {
|
|
168
|
+
let nextId = 1;
|
|
169
|
+
let closed = false;
|
|
170
|
+
let pending = new Map();
|
|
171
|
+
function settle(id, outcome) {
|
|
172
|
+
let entry = pending.get(id);
|
|
173
|
+
if (entry === undefined)
|
|
174
|
+
return undefined;
|
|
175
|
+
pending.delete(id);
|
|
176
|
+
entry.settle(outcome);
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
function close(error) {
|
|
180
|
+
if (closed)
|
|
181
|
+
return undefined;
|
|
182
|
+
closed = true;
|
|
183
|
+
for (let id of pending.keys())
|
|
184
|
+
settle(id, failure(error));
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
async function pumpReplies() {
|
|
188
|
+
try {
|
|
189
|
+
for await (let line of readLines(child.stdout)) {
|
|
190
|
+
if (line.trim() === "")
|
|
191
|
+
continue;
|
|
192
|
+
let reply = parseWireReply(line);
|
|
193
|
+
if (reply === null) {
|
|
194
|
+
close(new ToolError(`The plugin broke the wire protocol with a malformed reply line: ${truncate(line)}`));
|
|
195
|
+
child.kill();
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
if ("error" in reply) {
|
|
199
|
+
settle(reply.id, failure(reconstructWireError(reply.error)));
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
settle(reply.id, success(reply.result));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
close(new ToolError("The plugin process closed the connection"));
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
close(new ToolError(`Reading from the plugin failed: ${errorMessage(error)}`));
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
void pumpReplies();
|
|
213
|
+
return {
|
|
214
|
+
request(body, timeoutMs) {
|
|
215
|
+
if (closed) {
|
|
216
|
+
return Promise.resolve(failure(new ToolError("The plugin process is no longer running")));
|
|
217
|
+
}
|
|
218
|
+
let id = nextId;
|
|
219
|
+
nextId += 1;
|
|
220
|
+
return new Promise((resolve) => {
|
|
221
|
+
let timer;
|
|
222
|
+
pending.set(id, {
|
|
223
|
+
settle(outcome) {
|
|
224
|
+
if (timer !== undefined)
|
|
225
|
+
clearTimeout(timer);
|
|
226
|
+
resolve(outcome);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
if (timeoutMs !== undefined) {
|
|
230
|
+
timer = setTimeout(() => {
|
|
231
|
+
settle(id, failure(new ToolError(`The plugin did not reply to "${body.method}" within ${timeoutMs}ms`)));
|
|
232
|
+
}, timeoutMs);
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
child.stdin.write(`${JSON.stringify({ id, ...body })}\n`);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
settle(id, failure(new ToolError(`Writing to the plugin failed: ${errorMessage(error)}`)));
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
/** An already-exited child counts as killed too, so the close below always runs. */
|
|
243
|
+
kill() {
|
|
244
|
+
try {
|
|
245
|
+
child.kill();
|
|
246
|
+
}
|
|
247
|
+
catch { }
|
|
248
|
+
close(new ToolError("The plugin connection was closed"));
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Build the `ToolContext` the serving side hands its local plugin: relative
|
|
254
|
+
* paths resolve inside the forwarded workspace root and traversal out is
|
|
255
|
+
* refused, while permission checks stay permissive behind the host's gate.
|
|
256
|
+
*
|
|
257
|
+
* The instant crosses the wire, so a plugin here reads the same time the test
|
|
258
|
+
* started. The stream does not: it opens on a fixed seed, giving a served
|
|
259
|
+
* plugin values that repeat run to run. Carrying the host's stream position
|
|
260
|
+
* across a process boundary waits for a plugin that generates data.
|
|
261
|
+
*/
|
|
262
|
+
function createWireContext(workspaceRoot, now) {
|
|
263
|
+
let workspace = {
|
|
264
|
+
root: workspaceRoot,
|
|
265
|
+
resolve(target) {
|
|
266
|
+
if (path.isAbsolute(target)) {
|
|
267
|
+
return failure(new PermissionDeniedError("host-fs", target, "spec run --allow-host-fs=<directory>"));
|
|
268
|
+
}
|
|
269
|
+
let resolved = path.resolve(workspaceRoot, target);
|
|
270
|
+
let relative = path.relative(workspaceRoot, resolved);
|
|
271
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`)) {
|
|
272
|
+
return failure(new WorkspaceEscapeError(target));
|
|
273
|
+
}
|
|
274
|
+
return success(resolved);
|
|
275
|
+
},
|
|
276
|
+
async cleanup() {
|
|
277
|
+
return undefined;
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
let permissions = {
|
|
281
|
+
checkRun() {
|
|
282
|
+
return success(undefined);
|
|
283
|
+
},
|
|
284
|
+
checkNet() {
|
|
285
|
+
return success(undefined);
|
|
286
|
+
},
|
|
287
|
+
checkEnv() {
|
|
288
|
+
return success(undefined);
|
|
289
|
+
},
|
|
290
|
+
checkHostFs() {
|
|
291
|
+
return success(undefined);
|
|
292
|
+
},
|
|
293
|
+
grantedEnvNames() {
|
|
294
|
+
return [];
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
return {
|
|
298
|
+
workspace,
|
|
299
|
+
permissions,
|
|
300
|
+
random: createRandom(WIRE_SEED),
|
|
301
|
+
now: now === undefined ? new Date() : new Date(now),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** Write one reply line to stdout, the serving side's half of the wire. */
|
|
305
|
+
function writeReply(reply) {
|
|
306
|
+
process.stdout.write(`${JSON.stringify(reply)}\n`);
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Split a byte stream into newline-delimited lines.
|
|
311
|
+
*
|
|
312
|
+
* @yields One line at a time, without its terminating newline.
|
|
313
|
+
*/
|
|
314
|
+
async function* readLines(stream) {
|
|
315
|
+
let decoder = new TextDecoder();
|
|
316
|
+
let buffer = "";
|
|
317
|
+
for await (let chunk of stream) {
|
|
318
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
319
|
+
let index = buffer.indexOf("\n");
|
|
320
|
+
while (index !== -1) {
|
|
321
|
+
yield buffer.slice(0, index);
|
|
322
|
+
buffer = buffer.slice(index + 1);
|
|
323
|
+
index = buffer.indexOf("\n");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
buffer += decoder.decode();
|
|
327
|
+
if (buffer.length > 0)
|
|
328
|
+
yield buffer;
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
/** Parse one plugin→host reply line; null means the line broke the protocol. */
|
|
332
|
+
function parseWireReply(line) {
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(line);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
341
|
+
return null;
|
|
342
|
+
let record = parsed;
|
|
343
|
+
if (typeof record.id !== "number")
|
|
344
|
+
return null;
|
|
345
|
+
if ("error" in record) {
|
|
346
|
+
if (typeof record.error !== "object" || record.error === null)
|
|
347
|
+
return null;
|
|
348
|
+
let wire = record.error;
|
|
349
|
+
return {
|
|
350
|
+
id: record.id,
|
|
351
|
+
error: {
|
|
352
|
+
code: typeof wire.code === "string" ? wire.code : "tool-error",
|
|
353
|
+
message: typeof wire.message === "string"
|
|
354
|
+
? wire.message
|
|
355
|
+
: "The plugin reported an error without a message",
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
return { id: record.id, result: record.result ?? null };
|
|
360
|
+
}
|
|
361
|
+
/** Parse one host→plugin request line; null means it broke the protocol. */
|
|
362
|
+
function parseWireRequest(line) {
|
|
363
|
+
let parsed;
|
|
364
|
+
try {
|
|
365
|
+
parsed = JSON.parse(line);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
371
|
+
return null;
|
|
372
|
+
let record = parsed;
|
|
373
|
+
if (typeof record.id !== "number" || typeof record.method !== "string")
|
|
374
|
+
return null;
|
|
375
|
+
return {
|
|
376
|
+
id: record.id,
|
|
377
|
+
method: record.method,
|
|
378
|
+
tool: typeof record.tool === "string" ? record.tool : undefined,
|
|
379
|
+
args: Array.isArray(record.args) ? record.args : undefined,
|
|
380
|
+
workspaceRoot: typeof record.workspaceRoot === "string" ? record.workspaceRoot : undefined,
|
|
381
|
+
now: typeof record.now === "string" ? record.now : undefined,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/** Rebuild a `SpecError` from its wire form, defaulting unknown codes. */
|
|
385
|
+
function reconstructWireError(wire) {
|
|
386
|
+
let code = WIRE_CODES.has(wire.code)
|
|
387
|
+
? wire.code
|
|
388
|
+
: "tool-error";
|
|
389
|
+
return new SpecError(code, wire.message);
|
|
390
|
+
}
|
|
391
|
+
/** Render an unknown thrown value as a one-line message. */
|
|
392
|
+
function errorMessage(error) {
|
|
393
|
+
return error instanceof Error ? error.message : String(error);
|
|
394
|
+
}
|
|
395
|
+
/** Cap a wire line for inclusion in an error message. */
|
|
396
|
+
function truncate(line) {
|
|
397
|
+
if (line.length <= 120)
|
|
398
|
+
return line;
|
|
399
|
+
return `${line.slice(0, 120)}…`;
|
|
400
|
+
}
|
package/dist/values.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime value model: what `.spec` expressions evaluate to and what
|
|
3
|
+
* tools receive and return. Values are deliberately JSON-shaped so they
|
|
4
|
+
* cross the plugin protocol boundary already serialized.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/** An object value: string keys to nested values, as literal syntax builds. */
|
|
10
|
+
export interface ValueObject {
|
|
11
|
+
[key: string]: Value;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Everything a `.spec` expression can evaluate to. Duration literals
|
|
15
|
+
* evaluate to their number of milliseconds, represented by the plain
|
|
16
|
+
* `number` case above.
|
|
17
|
+
*/
|
|
18
|
+
export type Value = string | number | boolean | null | Value[] | ValueObject;
|
|
19
|
+
/**
|
|
20
|
+
* One argument to a tool call. A `word` is a bare identifier in argument
|
|
21
|
+
* position (`exists`, `textbox`, `with`) — a symbol the tool's descriptor
|
|
22
|
+
* interprets, carrying its own tag apart from a same-spelled string value.
|
|
23
|
+
*/
|
|
24
|
+
export type ToolArg = {
|
|
25
|
+
kind: "value";
|
|
26
|
+
value: Value;
|
|
27
|
+
} | {
|
|
28
|
+
kind: "word";
|
|
29
|
+
word: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Deep structural equality over runtime values — the semantics of the
|
|
33
|
+
* two-argument `expect A B` form. Arrays compare by index, objects by key
|
|
34
|
+
* set, primitives by `===`.
|
|
35
|
+
*
|
|
36
|
+
* @param left - One value.
|
|
37
|
+
* @param right - The other value.
|
|
38
|
+
* @returns Whether the two values are structurally identical.
|
|
39
|
+
*/
|
|
40
|
+
export declare function valueEquals(left: Value, right: Value): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Render a value for diagnostics: JSON with stable key order and indentation
|
|
43
|
+
* only when the value spans structures, so simple failures stay on one line.
|
|
44
|
+
*
|
|
45
|
+
* @param value - The value to render.
|
|
46
|
+
* @returns A human-readable, deterministic rendering.
|
|
47
|
+
*/
|
|
48
|
+
export declare function formatValue(value: Value): string;
|
package/dist/values.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtime value model: what `.spec` expressions evaluate to and what
|
|
3
|
+
* tools receive and return. Values are deliberately JSON-shaped so they
|
|
4
|
+
* cross the plugin protocol boundary already serialized.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Deep structural equality over runtime values — the semantics of the
|
|
11
|
+
* two-argument `expect A B` form. Arrays compare by index, objects by key
|
|
12
|
+
* set, primitives by `===`.
|
|
13
|
+
*
|
|
14
|
+
* @param left - One value.
|
|
15
|
+
* @param right - The other value.
|
|
16
|
+
* @returns Whether the two values are structurally identical.
|
|
17
|
+
*/
|
|
18
|
+
export function valueEquals(left, right) {
|
|
19
|
+
if (left === right)
|
|
20
|
+
return true;
|
|
21
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
22
|
+
if (left.length !== right.length)
|
|
23
|
+
return false;
|
|
24
|
+
return left.every((item, index) => valueEquals(item, right[index] ?? null));
|
|
25
|
+
}
|
|
26
|
+
if (typeof left === "object" &&
|
|
27
|
+
left !== null &&
|
|
28
|
+
!Array.isArray(left) &&
|
|
29
|
+
typeof right === "object" &&
|
|
30
|
+
right !== null &&
|
|
31
|
+
!Array.isArray(right)) {
|
|
32
|
+
let leftKeys = Object.keys(left);
|
|
33
|
+
let rightKeys = Object.keys(right);
|
|
34
|
+
if (leftKeys.length !== rightKeys.length)
|
|
35
|
+
return false;
|
|
36
|
+
return leftKeys.every((key) => key in right && valueEquals(left[key] ?? null, right[key] ?? null));
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Render a value for diagnostics: JSON with stable key order and indentation
|
|
42
|
+
* only when the value spans structures, so simple failures stay on one line.
|
|
43
|
+
*
|
|
44
|
+
* @param value - The value to render.
|
|
45
|
+
* @returns A human-readable, deterministic rendering.
|
|
46
|
+
*/
|
|
47
|
+
export function formatValue(value) {
|
|
48
|
+
let flat = JSON.stringify(value);
|
|
49
|
+
if (flat !== undefined && flat.length <= 60)
|
|
50
|
+
return flat;
|
|
51
|
+
return JSON.stringify(value, null, 2);
|
|
52
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry point for running specs inside a V8-isolate runtime — a
|
|
3
|
+
* Cloudflare Worker and anywhere else without a process, filesystem, or
|
|
4
|
+
* shell. It exports the language core plus the pure `http`, `url`, `jwt`, and
|
|
5
|
+
* `sample` capabilities; `db`, `cli`, `browser`, and stdio pull in Bun's SQL
|
|
6
|
+
* client or the `Bun` global, so importing them would break here.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export type { Random, Seed } from "@sdxc/sample";
|
|
12
|
+
export type * from "./ast.js";
|
|
13
|
+
export type { Sink, SuiteResult, TestResult, TestStatus } from "./diagnostics.js";
|
|
14
|
+
export { ExpectationError, LoadError, ParseError, PermissionDeniedError, ResolutionError, SpecError, ToolError, WorkspaceEscapeError, } from "./errors.js";
|
|
15
|
+
export type { DiagnosticCode } from "./errors.js";
|
|
16
|
+
export { executeTest } from "./executor.js";
|
|
17
|
+
export type { ExecutionContext } from "./executor.js";
|
|
18
|
+
export { lex } from "./lexer.js";
|
|
19
|
+
export { parse } from "./parser.js";
|
|
20
|
+
export { createPermissionSet, parseGrants } from "./permissions.js";
|
|
21
|
+
export type { Grant, Grants, PermissionKind, PermissionSet } from "./permissions.js";
|
|
22
|
+
export type { Plugin, ToolContext, ToolDescriptor, ToolParam } from "./plugin.js";
|
|
23
|
+
export { createHttpPlugin } from "./plugins/http.js";
|
|
24
|
+
export { createJwtPlugin } from "./plugins/jwt.js";
|
|
25
|
+
export { createSamplePlugin } from "./plugins/sample.js";
|
|
26
|
+
export { createUrlPlugin } from "./plugins/url.js";
|
|
27
|
+
export { createRegistry } from "./registry.js";
|
|
28
|
+
export type { Registry, ResolvedCallable } from "./registry.js";
|
|
29
|
+
export { runTests } from "./run.js";
|
|
30
|
+
export type { RunTestsOptions, WorkspaceFactory } from "./run.js";
|
|
31
|
+
export { positionAt } from "./source.js";
|
|
32
|
+
export type { Position, SourceFile, Span } from "./source.js";
|
|
33
|
+
export { loadSources } from "./sources.js";
|
|
34
|
+
export type { LoadedSuite, SpecSource } from "./sources.js";
|
|
35
|
+
export type { Token, TokenKind } from "./tokens.js";
|
|
36
|
+
export { KEYWORDS } from "./tokens.js";
|
|
37
|
+
export { formatValue, valueEquals } from "./values.js";
|
|
38
|
+
export type { ToolArg, Value, ValueObject } from "./values.js";
|
|
39
|
+
export type { Workspace } from "./workspace.js";
|
|
40
|
+
export { createNoFilesystemWorkspace } from "./workspace-none.js";
|
package/dist/workers.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The entry point for running specs inside a V8-isolate runtime — a
|
|
3
|
+
* Cloudflare Worker and anywhere else without a process, filesystem, or
|
|
4
|
+
* shell. It exports the language core plus the pure `http`, `url`, `jwt`, and
|
|
5
|
+
* `sample` capabilities; `db`, `cli`, `browser`, and stdio pull in Bun's SQL
|
|
6
|
+
* client or the `Bun` global, so importing them would break here.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
export { ExpectationError, LoadError, ParseError, PermissionDeniedError, ResolutionError, SpecError, ToolError, WorkspaceEscapeError, } from "./errors.js";
|
|
12
|
+
export { executeTest } from "./executor.js";
|
|
13
|
+
export { lex } from "./lexer.js";
|
|
14
|
+
export { parse } from "./parser.js";
|
|
15
|
+
export { createPermissionSet, parseGrants } from "./permissions.js";
|
|
16
|
+
export { createHttpPlugin } from "./plugins/http.js";
|
|
17
|
+
export { createJwtPlugin } from "./plugins/jwt.js";
|
|
18
|
+
export { createSamplePlugin } from "./plugins/sample.js";
|
|
19
|
+
export { createUrlPlugin } from "./plugins/url.js";
|
|
20
|
+
export { createRegistry } from "./registry.js";
|
|
21
|
+
export { runTests } from "./run.js";
|
|
22
|
+
export { positionAt } from "./source.js";
|
|
23
|
+
export { loadSources } from "./sources.js";
|
|
24
|
+
export { KEYWORDS } from "./tokens.js";
|
|
25
|
+
export { formatValue, valueEquals } from "./values.js";
|
|
26
|
+
export { createNoFilesystemWorkspace } from "./workspace-none.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace for a runtime that has no filesystem: every path is refused
|
|
3
|
+
* and `cleanup` is a no-op, since there is nothing to remove. Without `fs`
|
|
4
|
+
* or `cli` registered, a spec can never reach `resolve`; this workspace
|
|
5
|
+
* makes that absence legible, so a spec naming a path gets a `ToolError` in
|
|
6
|
+
* the language's normal diagnostic shape, naming the exact path it tried.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import type { Result } from "@sdxc/result";
|
|
12
|
+
import type { SpecError } from "./errors.js";
|
|
13
|
+
import type { Workspace } from "./workspace.js";
|
|
14
|
+
/**
|
|
15
|
+
* Create a workspace that refuses every path, shaped as an async factory
|
|
16
|
+
* returning a `Result` so it works directly as `runTests`'s `createWorkspace`
|
|
17
|
+
* like the on-disk factory that can genuinely fail — this one never does.
|
|
18
|
+
*
|
|
19
|
+
* @returns A workspace whose `resolve` always fails, whose `cleanup` does
|
|
20
|
+
* nothing, and whose `root` is a placeholder string that no resolution ever
|
|
21
|
+
* joins onto.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createNoFilesystemWorkspace(): Promise<Result<Workspace, SpecError>>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace for a runtime that has no filesystem: every path is refused
|
|
3
|
+
* and `cleanup` is a no-op, since there is nothing to remove. Without `fs`
|
|
4
|
+
* or `cli` registered, a spec can never reach `resolve`; this workspace
|
|
5
|
+
* makes that absence legible, so a spec naming a path gets a `ToolError` in
|
|
6
|
+
* the language's normal diagnostic shape, naming the exact path it tried.
|
|
7
|
+
*
|
|
8
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
9
|
+
* @copyright Sergio Xalambrí 2026
|
|
10
|
+
*/
|
|
11
|
+
import { failure, success } from "@sdxc/result";
|
|
12
|
+
import { ToolError } from "./errors.js";
|
|
13
|
+
/**
|
|
14
|
+
* Create a workspace that refuses every path, shaped as an async factory
|
|
15
|
+
* returning a `Result` so it works directly as `runTests`'s `createWorkspace`
|
|
16
|
+
* like the on-disk factory that can genuinely fail — this one never does.
|
|
17
|
+
*
|
|
18
|
+
* @returns A workspace whose `resolve` always fails, whose `cleanup` does
|
|
19
|
+
* nothing, and whose `root` is a placeholder string that no resolution ever
|
|
20
|
+
* joins onto.
|
|
21
|
+
*/
|
|
22
|
+
export async function createNoFilesystemWorkspace() {
|
|
23
|
+
let workspace = {
|
|
24
|
+
root: "<no filesystem>",
|
|
25
|
+
resolve(path) {
|
|
26
|
+
return failure(new ToolError(`Cannot resolve "${path}": this run has no filesystem, so no path can be read or written.`));
|
|
27
|
+
},
|
|
28
|
+
async cleanup() {
|
|
29
|
+
return undefined;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
return success(workspace);
|
|
33
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolated per-test workspace: a runtime primitive every capability
|
|
3
|
+
* shares, not a filesystem-plugin detail. Each test gets a fresh ephemeral
|
|
4
|
+
* directory that `fs` tools write into, `cli` processes start in, and
|
|
5
|
+
* assertions inspect; the runtime cleans it up when the test ends.
|
|
6
|
+
*
|
|
7
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
8
|
+
* @copyright Sergio Xalambrí 2026
|
|
9
|
+
*/
|
|
10
|
+
import type { Result } from "@sdxc/result";
|
|
11
|
+
import type { PermissionSet } from "./permissions.js";
|
|
12
|
+
import { SpecError } from "./errors.js";
|
|
13
|
+
/**
|
|
14
|
+
* One test's isolated workspace. Path resolution is the safety boundary:
|
|
15
|
+
* workspace-relative paths are safe by default, while absolute paths and
|
|
16
|
+
* paths that traverse out of the root require a host-filesystem grant.
|
|
17
|
+
*/
|
|
18
|
+
export interface Workspace {
|
|
19
|
+
/** Absolute host path of the workspace root (a fresh temp directory). */
|
|
20
|
+
root: string;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a spec-written path to an absolute host path. Relative paths
|
|
23
|
+
* must stay inside the root, symlinks re-resolved before the check;
|
|
24
|
+
* absolute paths delegate to the host-fs permission grant.
|
|
25
|
+
*
|
|
26
|
+
* @param path - The spec-written path to resolve.
|
|
27
|
+
* @returns The absolute host path, or a `WorkspaceEscapeError` when the
|
|
28
|
+
* path would escape the root — including when an existing ancestor's
|
|
29
|
+
* symlink target cannot be verified — or the host-fs permission's denial
|
|
30
|
+
* for absolute paths.
|
|
31
|
+
*/
|
|
32
|
+
resolve(path: string): Result<string, SpecError>;
|
|
33
|
+
/**
|
|
34
|
+
* Remove the workspace directory and everything in it; resolves
|
|
35
|
+
* unconditionally, keeping removal failures harmless to the test run.
|
|
36
|
+
*/
|
|
37
|
+
cleanup(): Promise<undefined>;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Create a fresh isolated workspace: a temp directory whose real path
|
|
41
|
+
* (often behind a symlink) bounds every relative path, symlinked
|
|
42
|
+
* ancestors included; absolute paths delegate to the host-fs permission.
|
|
43
|
+
*
|
|
44
|
+
* @param permissions - The run's permission set, consulted for absolute paths.
|
|
45
|
+
* @returns The workspace, or the error that prevented creating its directory.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createWorkspace(permissions: PermissionSet): Promise<Result<Workspace, SpecError>>;
|