@vexyo/core 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 +202 -0
- package/README.md +11 -0
- package/dist/index.d.ts +400 -0
- package/dist/index.js +1406 -0
- package/dist/index.js.map +1 -0
- package/package.json +44 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1406 @@
|
|
|
1
|
+
// src/types.ts
|
|
2
|
+
var STABLE_SPEC_VERSION = "2025-11-25";
|
|
3
|
+
|
|
4
|
+
// src/rule.ts
|
|
5
|
+
var SkipRule = class extends Error {
|
|
6
|
+
constructor(reason) {
|
|
7
|
+
super(reason);
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.name = "SkipRule";
|
|
10
|
+
}
|
|
11
|
+
reason;
|
|
12
|
+
};
|
|
13
|
+
function finding(rule, message, remediation, detail) {
|
|
14
|
+
return {
|
|
15
|
+
ruleId: rule.id,
|
|
16
|
+
severity: rule.severity,
|
|
17
|
+
message,
|
|
18
|
+
remediation,
|
|
19
|
+
specRef: rule.specRef,
|
|
20
|
+
detail
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/rules/2025-11-25/init/server-info-name.ts
|
|
25
|
+
var serverInfoName = {
|
|
26
|
+
id: "init/server-info-name",
|
|
27
|
+
specVersion: "2025-11-25",
|
|
28
|
+
category: "initialization",
|
|
29
|
+
severity: "error",
|
|
30
|
+
title: "Server advertises a non-empty serverInfo.name",
|
|
31
|
+
specRef: "Base Protocol \xA7Lifecycle / Initialization (serverInfo)",
|
|
32
|
+
async run(ctx) {
|
|
33
|
+
const name = ctx.serverInfo?.name;
|
|
34
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
35
|
+
return [
|
|
36
|
+
finding(
|
|
37
|
+
this,
|
|
38
|
+
"Server did not return a non-empty `serverInfo.name` during initialization.",
|
|
39
|
+
"Return a non-empty `serverInfo.name` in the initialize response.",
|
|
40
|
+
{ serverInfo: ctx.serverInfo }
|
|
41
|
+
)
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// src/rules/2025-11-25/init/server-info-version.ts
|
|
49
|
+
var serverInfoVersion = {
|
|
50
|
+
id: "init/server-info-version",
|
|
51
|
+
specVersion: "2025-11-25",
|
|
52
|
+
category: "initialization",
|
|
53
|
+
severity: "error",
|
|
54
|
+
title: "Server advertises a non-empty serverInfo.version",
|
|
55
|
+
specRef: "Base Protocol \xA7Lifecycle / Initialization (serverInfo)",
|
|
56
|
+
async run(ctx) {
|
|
57
|
+
const version = ctx.serverInfo?.version;
|
|
58
|
+
if (typeof version !== "string" || version.length === 0) {
|
|
59
|
+
return [
|
|
60
|
+
finding(
|
|
61
|
+
this,
|
|
62
|
+
"Server did not return a non-empty `serverInfo.version` during initialization.",
|
|
63
|
+
"Return a non-empty `serverInfo.version` in the initialize response.",
|
|
64
|
+
{ serverInfo: ctx.serverInfo }
|
|
65
|
+
)
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// src/rules/2025-11-25/support.ts
|
|
73
|
+
import { z as z2 } from "zod";
|
|
74
|
+
|
|
75
|
+
// src/mcp/errors.ts
|
|
76
|
+
import { McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
77
|
+
function cleanErrorMessage(err) {
|
|
78
|
+
let message = err instanceof Error ? err.message : String(err);
|
|
79
|
+
const prefix = /^MCP error -?\d+: /;
|
|
80
|
+
while (prefix.test(message)) {
|
|
81
|
+
message = message.replace(prefix, "");
|
|
82
|
+
}
|
|
83
|
+
return message;
|
|
84
|
+
}
|
|
85
|
+
function errorCode(err) {
|
|
86
|
+
return err instanceof McpError ? err.code : void 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/mcp/schemas.ts
|
|
90
|
+
import { z } from "zod";
|
|
91
|
+
var toolsListResult = z.object({ tools: z.array(z.record(z.string(), z.unknown())) });
|
|
92
|
+
var resourcesListResult = z.object({
|
|
93
|
+
resources: z.array(z.record(z.string(), z.unknown()))
|
|
94
|
+
});
|
|
95
|
+
var promptsListResult = z.object({ prompts: z.array(z.record(z.string(), z.unknown())) });
|
|
96
|
+
var anyResult = z.unknown();
|
|
97
|
+
function isRecord(value) {
|
|
98
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
99
|
+
}
|
|
100
|
+
function stringField(record, key) {
|
|
101
|
+
const value = record[key];
|
|
102
|
+
return typeof value === "string" ? value : void 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/rules/2025-11-25/support.ts
|
|
106
|
+
var callToolResult = z2.object({ isError: z2.boolean().optional() });
|
|
107
|
+
function classifyError(err) {
|
|
108
|
+
return { threw: true, code: errorCode(err), message: cleanErrorMessage(err) };
|
|
109
|
+
}
|
|
110
|
+
async function requireToolList(ctx) {
|
|
111
|
+
try {
|
|
112
|
+
return (await ctx.client.request({ method: "tools/list", params: {} }, toolsListResult)).tools;
|
|
113
|
+
} catch {
|
|
114
|
+
throw new SkipRule("tools/list unavailable");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async function requireResourceList(ctx) {
|
|
118
|
+
try {
|
|
119
|
+
return (await ctx.client.request({ method: "resources/list", params: {} }, resourcesListResult)).resources;
|
|
120
|
+
} catch {
|
|
121
|
+
throw new SkipRule("resources/list unavailable");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function requirePromptList(ctx) {
|
|
125
|
+
try {
|
|
126
|
+
return (await ctx.client.request({ method: "prompts/list", params: {} }, promptsListResult)).prompts;
|
|
127
|
+
} catch {
|
|
128
|
+
throw new SkipRule("prompts/list unavailable");
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function toolWithRequiredParams(tools) {
|
|
132
|
+
for (const tool of tools) {
|
|
133
|
+
const name = stringField(tool, "name");
|
|
134
|
+
const schema = tool["inputSchema"];
|
|
135
|
+
if (name && isRecord(schema) && Array.isArray(schema["required"]) && schema["required"].length > 0) {
|
|
136
|
+
return name;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// src/rules/2025-11-25/discovery/tools-list.ts
|
|
143
|
+
var toolsListAvailable = {
|
|
144
|
+
id: "discovery/tools-list-available",
|
|
145
|
+
specVersion: "2025-11-25",
|
|
146
|
+
category: "discovery",
|
|
147
|
+
severity: "error",
|
|
148
|
+
title: "tools/list responds with a tool array",
|
|
149
|
+
specRef: "Server Features \xA7Tools / Listing Tools",
|
|
150
|
+
async run(ctx) {
|
|
151
|
+
if (!ctx.capabilities?.tools) {
|
|
152
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await ctx.client.request({ method: "tools/list", params: {} }, toolsListResult);
|
|
156
|
+
return [];
|
|
157
|
+
} catch (err) {
|
|
158
|
+
return [
|
|
159
|
+
finding(
|
|
160
|
+
this,
|
|
161
|
+
"`tools/list` failed despite the `tools` capability being advertised.",
|
|
162
|
+
"Implement `tools/list` to return `{ tools: Tool[] }`, or stop advertising the `tools` capability.",
|
|
163
|
+
{ error: cleanErrorMessage(err) }
|
|
164
|
+
)
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/rules/2025-11-25/discovery/tools-have-names.ts
|
|
171
|
+
var toolsHaveNames = {
|
|
172
|
+
id: "discovery/tools-have-names",
|
|
173
|
+
specVersion: "2025-11-25",
|
|
174
|
+
category: "discovery",
|
|
175
|
+
severity: "error",
|
|
176
|
+
title: "Every tool has a non-empty name",
|
|
177
|
+
specRef: "Server Features \xA7Tools / Tool.name",
|
|
178
|
+
async run(ctx) {
|
|
179
|
+
if (!ctx.capabilities?.tools) {
|
|
180
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
181
|
+
}
|
|
182
|
+
const tools = await requireToolList(ctx);
|
|
183
|
+
const invalid = tools.filter((tool) => {
|
|
184
|
+
const name = stringField(tool, "name");
|
|
185
|
+
return name === void 0 || name.length === 0;
|
|
186
|
+
});
|
|
187
|
+
if (invalid.length > 0) {
|
|
188
|
+
return [
|
|
189
|
+
finding(
|
|
190
|
+
this,
|
|
191
|
+
`${invalid.length} tool(s) have a missing or empty name.`,
|
|
192
|
+
"Give every tool a non-empty string `name`.",
|
|
193
|
+
{ tools }
|
|
194
|
+
)
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// src/rules/2025-11-25/discovery/tools-have-input-schema.ts
|
|
202
|
+
var toolsHaveInputSchema = {
|
|
203
|
+
id: "discovery/tools-have-input-schema",
|
|
204
|
+
specVersion: "2025-11-25",
|
|
205
|
+
category: "discovery",
|
|
206
|
+
severity: "error",
|
|
207
|
+
title: "Every tool declares an object inputSchema",
|
|
208
|
+
specRef: "Server Features \xA7Tools / Tool.inputSchema",
|
|
209
|
+
async run(ctx) {
|
|
210
|
+
if (!ctx.capabilities?.tools) {
|
|
211
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
212
|
+
}
|
|
213
|
+
const tools = await requireToolList(ctx);
|
|
214
|
+
const invalid = tools.filter((tool) => {
|
|
215
|
+
const schema = tool["inputSchema"];
|
|
216
|
+
return !isRecord(schema) || schema["type"] !== "object";
|
|
217
|
+
});
|
|
218
|
+
if (invalid.length > 0) {
|
|
219
|
+
return [
|
|
220
|
+
finding(
|
|
221
|
+
this,
|
|
222
|
+
`${invalid.length} tool(s) are missing an object inputSchema (type: "object").`,
|
|
223
|
+
'Declare an `inputSchema` of `{ "type": "object", ... }` for every tool.',
|
|
224
|
+
{ tools }
|
|
225
|
+
)
|
|
226
|
+
];
|
|
227
|
+
}
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
// src/rules/2025-11-25/discovery/tool-names-unique.ts
|
|
233
|
+
var toolNamesUnique = {
|
|
234
|
+
id: "discovery/tool-names-unique",
|
|
235
|
+
specVersion: "2025-11-25",
|
|
236
|
+
category: "discovery",
|
|
237
|
+
severity: "error",
|
|
238
|
+
title: "Tool names are unique",
|
|
239
|
+
specRef: "Server Features \xA7Tools / Tool.name",
|
|
240
|
+
async run(ctx) {
|
|
241
|
+
if (!ctx.capabilities?.tools) {
|
|
242
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
243
|
+
}
|
|
244
|
+
const tools = await requireToolList(ctx);
|
|
245
|
+
const names = tools.map((tool) => stringField(tool, "name")).filter((n) => !!n);
|
|
246
|
+
const duplicates = [...new Set(names.filter((name, i) => names.indexOf(name) !== i))];
|
|
247
|
+
if (duplicates.length > 0) {
|
|
248
|
+
return [
|
|
249
|
+
finding(
|
|
250
|
+
this,
|
|
251
|
+
`Duplicate tool name(s): ${duplicates.join(", ")}.`,
|
|
252
|
+
"Ensure every tool has a distinct `name`.",
|
|
253
|
+
{ duplicates }
|
|
254
|
+
)
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// src/rules/2025-11-25/discovery/resources-list.ts
|
|
262
|
+
var resourcesListAvailable = {
|
|
263
|
+
id: "discovery/resources-list-available",
|
|
264
|
+
specVersion: "2025-11-25",
|
|
265
|
+
category: "discovery",
|
|
266
|
+
severity: "error",
|
|
267
|
+
title: "resources/list responds with a resource array",
|
|
268
|
+
specRef: "Server Features \xA7Resources / Listing Resources",
|
|
269
|
+
async run(ctx) {
|
|
270
|
+
if (!ctx.capabilities?.resources) {
|
|
271
|
+
throw new SkipRule("Server does not advertise the `resources` capability.");
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await ctx.client.request({ method: "resources/list", params: {} }, resourcesListResult);
|
|
275
|
+
return [];
|
|
276
|
+
} catch (err) {
|
|
277
|
+
return [
|
|
278
|
+
finding(
|
|
279
|
+
this,
|
|
280
|
+
"`resources/list` failed despite the `resources` capability being advertised.",
|
|
281
|
+
"Implement `resources/list`, or stop advertising the `resources` capability.",
|
|
282
|
+
{ error: cleanErrorMessage(err) }
|
|
283
|
+
)
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// src/rules/2025-11-25/discovery/resources-have-uri.ts
|
|
290
|
+
var resourcesHaveUri = {
|
|
291
|
+
id: "discovery/resources-have-uri",
|
|
292
|
+
specVersion: "2025-11-25",
|
|
293
|
+
category: "discovery",
|
|
294
|
+
severity: "error",
|
|
295
|
+
title: "Every resource has a non-empty uri",
|
|
296
|
+
specRef: "Server Features \xA7Resources / Resource.uri",
|
|
297
|
+
async run(ctx) {
|
|
298
|
+
if (!ctx.capabilities?.resources) {
|
|
299
|
+
throw new SkipRule("Server does not advertise the `resources` capability.");
|
|
300
|
+
}
|
|
301
|
+
const resources = await requireResourceList(ctx);
|
|
302
|
+
const invalid = resources.filter((resource) => {
|
|
303
|
+
const uri = stringField(resource, "uri");
|
|
304
|
+
return uri === void 0 || uri.length === 0;
|
|
305
|
+
});
|
|
306
|
+
if (invalid.length > 0) {
|
|
307
|
+
return [
|
|
308
|
+
finding(
|
|
309
|
+
this,
|
|
310
|
+
`${invalid.length} resource(s) have a missing or empty uri.`,
|
|
311
|
+
"Give every resource a non-empty `uri`.",
|
|
312
|
+
{ resources }
|
|
313
|
+
)
|
|
314
|
+
];
|
|
315
|
+
}
|
|
316
|
+
return [];
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// src/rules/2025-11-25/discovery/prompts-list.ts
|
|
321
|
+
var promptsListAvailable = {
|
|
322
|
+
id: "discovery/prompts-list-available",
|
|
323
|
+
specVersion: "2025-11-25",
|
|
324
|
+
category: "discovery",
|
|
325
|
+
severity: "error",
|
|
326
|
+
title: "prompts/list responds with a prompt array",
|
|
327
|
+
specRef: "Server Features \xA7Prompts / Listing Prompts",
|
|
328
|
+
async run(ctx) {
|
|
329
|
+
if (!ctx.capabilities?.prompts) {
|
|
330
|
+
throw new SkipRule("Server does not advertise the `prompts` capability.");
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
await ctx.client.request({ method: "prompts/list", params: {} }, promptsListResult);
|
|
334
|
+
return [];
|
|
335
|
+
} catch (err) {
|
|
336
|
+
return [
|
|
337
|
+
finding(
|
|
338
|
+
this,
|
|
339
|
+
"`prompts/list` failed despite the `prompts` capability being advertised.",
|
|
340
|
+
"Implement `prompts/list`, or stop advertising the `prompts` capability.",
|
|
341
|
+
{ error: cleanErrorMessage(err) }
|
|
342
|
+
)
|
|
343
|
+
];
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
// src/rules/2025-11-25/discovery/prompts-have-names.ts
|
|
349
|
+
var promptsHaveNames = {
|
|
350
|
+
id: "discovery/prompts-have-names",
|
|
351
|
+
specVersion: "2025-11-25",
|
|
352
|
+
category: "discovery",
|
|
353
|
+
severity: "error",
|
|
354
|
+
title: "Every prompt has a non-empty name",
|
|
355
|
+
specRef: "Server Features \xA7Prompts / Prompt.name",
|
|
356
|
+
async run(ctx) {
|
|
357
|
+
if (!ctx.capabilities?.prompts) {
|
|
358
|
+
throw new SkipRule("Server does not advertise the `prompts` capability.");
|
|
359
|
+
}
|
|
360
|
+
const prompts = await requirePromptList(ctx);
|
|
361
|
+
const invalid = prompts.filter((prompt) => {
|
|
362
|
+
const name = stringField(prompt, "name");
|
|
363
|
+
return name === void 0 || name.length === 0;
|
|
364
|
+
});
|
|
365
|
+
if (invalid.length > 0) {
|
|
366
|
+
return [
|
|
367
|
+
finding(
|
|
368
|
+
this,
|
|
369
|
+
`${invalid.length} prompt(s) have a missing or empty name.`,
|
|
370
|
+
"Give every prompt a non-empty string `name`.",
|
|
371
|
+
{ prompts }
|
|
372
|
+
)
|
|
373
|
+
];
|
|
374
|
+
}
|
|
375
|
+
return [];
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/rules/2025-11-25/errors/unknown-method.ts
|
|
380
|
+
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
381
|
+
var PROBE_UNSUPPORTED_METHOD = "vexyo/probe-unsupported-method";
|
|
382
|
+
var unknownMethod = {
|
|
383
|
+
id: "errors/unknown-method",
|
|
384
|
+
specVersion: "2025-11-25",
|
|
385
|
+
category: "error-semantics",
|
|
386
|
+
severity: "error",
|
|
387
|
+
title: "Unknown methods return -32601 (Method not found)",
|
|
388
|
+
specRef: "Base Protocol \xA7JSON-RPC / Error codes",
|
|
389
|
+
async run(ctx) {
|
|
390
|
+
const probe = { method: PROBE_UNSUPPORTED_METHOD, params: {} };
|
|
391
|
+
try {
|
|
392
|
+
await ctx.client.request(probe, anyResult);
|
|
393
|
+
return [
|
|
394
|
+
finding(
|
|
395
|
+
this,
|
|
396
|
+
"An unknown method returned a result instead of an error.",
|
|
397
|
+
"Return JSON-RPC error -32601 (Method not found) for methods the server does not implement."
|
|
398
|
+
)
|
|
399
|
+
];
|
|
400
|
+
} catch (err) {
|
|
401
|
+
const info = classifyError(err);
|
|
402
|
+
if (info.code === ErrorCode.MethodNotFound) {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
return [
|
|
406
|
+
finding(
|
|
407
|
+
this,
|
|
408
|
+
`Unknown method returned error code ${info.code ?? "(none)"}, expected -32601 (Method not found).`,
|
|
409
|
+
"Return JSON-RPC error -32601 for methods the server does not implement.",
|
|
410
|
+
info
|
|
411
|
+
)
|
|
412
|
+
];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
// src/rules/2025-11-25/errors/unknown-tool.ts
|
|
418
|
+
var NONEXISTENT_TOOL = "__vexyo_nonexistent_tool__";
|
|
419
|
+
var unknownTool = {
|
|
420
|
+
id: "errors/unknown-tool",
|
|
421
|
+
specVersion: "2025-11-25",
|
|
422
|
+
category: "error-semantics",
|
|
423
|
+
severity: "error",
|
|
424
|
+
title: "Calling an unknown tool is signalled as an error",
|
|
425
|
+
specRef: "Server Features \xA7Tools / Calling Tools (error handling)",
|
|
426
|
+
async run(ctx) {
|
|
427
|
+
if (!ctx.capabilities?.tools) {
|
|
428
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const result = await ctx.client.request(
|
|
432
|
+
{ method: "tools/call", params: { name: NONEXISTENT_TOOL, arguments: {} } },
|
|
433
|
+
callToolResult
|
|
434
|
+
);
|
|
435
|
+
if (result.isError === true) {
|
|
436
|
+
return [];
|
|
437
|
+
}
|
|
438
|
+
return [
|
|
439
|
+
finding(
|
|
440
|
+
this,
|
|
441
|
+
"Calling a nonexistent tool returned a successful result instead of an error.",
|
|
442
|
+
"Return a JSON-RPC error or a result with `isError: true` for unknown tool names.",
|
|
443
|
+
result
|
|
444
|
+
)
|
|
445
|
+
];
|
|
446
|
+
} catch {
|
|
447
|
+
return [];
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// src/rules/2025-11-25/errors/tool-invalid-params.ts
|
|
453
|
+
var toolInvalidParams = {
|
|
454
|
+
id: "errors/tool-invalid-params",
|
|
455
|
+
specVersion: "2025-11-25",
|
|
456
|
+
category: "error-semantics",
|
|
457
|
+
severity: "error",
|
|
458
|
+
title: "Invalid tool arguments are rejected",
|
|
459
|
+
specRef: "Server Features \xA7Tools / Calling Tools (input validation)",
|
|
460
|
+
async run(ctx) {
|
|
461
|
+
if (!ctx.capabilities?.tools) {
|
|
462
|
+
throw new SkipRule("Server does not advertise the `tools` capability.");
|
|
463
|
+
}
|
|
464
|
+
const tools = await requireToolList(ctx);
|
|
465
|
+
const target = toolWithRequiredParams(tools);
|
|
466
|
+
if (target === void 0) {
|
|
467
|
+
throw new SkipRule("No tool declares required parameters to probe.");
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const result = await ctx.client.request(
|
|
471
|
+
{ method: "tools/call", params: { name: target, arguments: {} } },
|
|
472
|
+
callToolResult
|
|
473
|
+
);
|
|
474
|
+
if (result.isError === true) {
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
return [
|
|
478
|
+
finding(
|
|
479
|
+
this,
|
|
480
|
+
`Tool "${target}" accepted a call with missing required arguments and returned success.`,
|
|
481
|
+
"Validate tool arguments and return an error (-32602 or `isError: true`) on invalid input.",
|
|
482
|
+
result
|
|
483
|
+
)
|
|
484
|
+
];
|
|
485
|
+
} catch {
|
|
486
|
+
return [];
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
|
|
491
|
+
// src/rules/2025-11-25/errors/unknown-resource.ts
|
|
492
|
+
var NONEXISTENT_URI = "vexyo://__vexyo_nonexistent_resource__";
|
|
493
|
+
var unknownResource = {
|
|
494
|
+
id: "errors/unknown-resource",
|
|
495
|
+
specVersion: "2025-11-25",
|
|
496
|
+
category: "error-semantics",
|
|
497
|
+
severity: "error",
|
|
498
|
+
title: "Reading an unknown resource returns an error",
|
|
499
|
+
specRef: "Server Features \xA7Resources / Reading Resources (error handling)",
|
|
500
|
+
async run(ctx) {
|
|
501
|
+
if (!ctx.capabilities?.resources) {
|
|
502
|
+
throw new SkipRule("Server does not advertise the `resources` capability.");
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
await ctx.client.request(
|
|
506
|
+
{ method: "resources/read", params: { uri: NONEXISTENT_URI } },
|
|
507
|
+
anyResult
|
|
508
|
+
);
|
|
509
|
+
return [
|
|
510
|
+
finding(
|
|
511
|
+
this,
|
|
512
|
+
"Reading a nonexistent resource URI returned a successful result instead of an error.",
|
|
513
|
+
"Return a JSON-RPC error for resource URIs the server does not serve."
|
|
514
|
+
)
|
|
515
|
+
];
|
|
516
|
+
} catch {
|
|
517
|
+
return [];
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// src/rules/2025-11-25/errors/error-object-shape.ts
|
|
523
|
+
var NONEXISTENT_URI2 = "vexyo://__vexyo_error_shape_probe__";
|
|
524
|
+
var errorObjectShape = {
|
|
525
|
+
id: "errors/error-object-shape",
|
|
526
|
+
specVersion: "2025-11-25",
|
|
527
|
+
category: "error-semantics",
|
|
528
|
+
severity: "error",
|
|
529
|
+
title: "JSON-RPC error objects carry a numeric code and non-empty message",
|
|
530
|
+
specRef: "Base Protocol \xA7JSON-RPC / Error object",
|
|
531
|
+
async run(ctx) {
|
|
532
|
+
if (!ctx.capabilities?.resources) {
|
|
533
|
+
throw new SkipRule("Server does not advertise the `resources` capability.");
|
|
534
|
+
}
|
|
535
|
+
try {
|
|
536
|
+
await ctx.client.request(
|
|
537
|
+
{ method: "resources/read", params: { uri: NONEXISTENT_URI2 } },
|
|
538
|
+
anyResult
|
|
539
|
+
);
|
|
540
|
+
throw new SkipRule("Server did not raise a JSON-RPC error for an unknown resource.");
|
|
541
|
+
} catch (err) {
|
|
542
|
+
if (err instanceof SkipRule) {
|
|
543
|
+
throw err;
|
|
544
|
+
}
|
|
545
|
+
const info = classifyError(err);
|
|
546
|
+
const problems = [];
|
|
547
|
+
if (typeof info.code !== "number") {
|
|
548
|
+
problems.push("missing numeric `code`");
|
|
549
|
+
}
|
|
550
|
+
if (info.message === void 0 || info.message.length === 0) {
|
|
551
|
+
problems.push("missing or empty `message`");
|
|
552
|
+
}
|
|
553
|
+
if (problems.length > 0) {
|
|
554
|
+
return [
|
|
555
|
+
finding(
|
|
556
|
+
this,
|
|
557
|
+
`JSON-RPC error object is malformed: ${problems.join(", ")}.`,
|
|
558
|
+
"Every JSON-RPC error must include a numeric `code` and a non-empty `message`.",
|
|
559
|
+
info
|
|
560
|
+
)
|
|
561
|
+
];
|
|
562
|
+
}
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
// src/rules/2025-11-25/transport/http-session-id-valid.ts
|
|
569
|
+
var VISIBLE_ASCII = /^[\x21-\x7e]+$/;
|
|
570
|
+
var httpSessionIdValid = {
|
|
571
|
+
id: "transport/http-session-id-valid",
|
|
572
|
+
specVersion: "2025-11-25",
|
|
573
|
+
category: "transport",
|
|
574
|
+
severity: "error",
|
|
575
|
+
title: "HTTP session id contains only visible ASCII",
|
|
576
|
+
specRef: "Transports \xA7Streamable HTTP / Session management",
|
|
577
|
+
async run(ctx) {
|
|
578
|
+
if (ctx.transport.kind !== "http") {
|
|
579
|
+
throw new SkipRule("Not an HTTP connection.");
|
|
580
|
+
}
|
|
581
|
+
const sessionId = ctx.transport.sessionId;
|
|
582
|
+
if (sessionId === void 0) {
|
|
583
|
+
throw new SkipRule("Server operates statelessly (no Mcp-Session-Id issued).");
|
|
584
|
+
}
|
|
585
|
+
if (!VISIBLE_ASCII.test(sessionId)) {
|
|
586
|
+
return [
|
|
587
|
+
finding(
|
|
588
|
+
this,
|
|
589
|
+
"The issued Mcp-Session-Id contains characters outside visible ASCII (0x21\u20130x7E).",
|
|
590
|
+
"Generate session ids using only visible ASCII characters (e.g. a UUID).",
|
|
591
|
+
{ sessionId }
|
|
592
|
+
)
|
|
593
|
+
];
|
|
594
|
+
}
|
|
595
|
+
return [];
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// src/rules/2025-11-25/index.ts
|
|
600
|
+
var rules2025_11_25 = [
|
|
601
|
+
// initialization
|
|
602
|
+
serverInfoName,
|
|
603
|
+
serverInfoVersion,
|
|
604
|
+
// discovery
|
|
605
|
+
toolsListAvailable,
|
|
606
|
+
toolsHaveNames,
|
|
607
|
+
toolsHaveInputSchema,
|
|
608
|
+
toolNamesUnique,
|
|
609
|
+
resourcesListAvailable,
|
|
610
|
+
resourcesHaveUri,
|
|
611
|
+
promptsListAvailable,
|
|
612
|
+
promptsHaveNames,
|
|
613
|
+
// error-semantics
|
|
614
|
+
unknownMethod,
|
|
615
|
+
unknownTool,
|
|
616
|
+
toolInvalidParams,
|
|
617
|
+
unknownResource,
|
|
618
|
+
errorObjectShape,
|
|
619
|
+
// transport
|
|
620
|
+
httpSessionIdValid
|
|
621
|
+
];
|
|
622
|
+
|
|
623
|
+
// src/registry.ts
|
|
624
|
+
var ALL_RULES = [...rules2025_11_25];
|
|
625
|
+
function rulesForSpecVersion(version) {
|
|
626
|
+
return ALL_RULES.filter((rule) => rule.specVersion === version);
|
|
627
|
+
}
|
|
628
|
+
function allRules() {
|
|
629
|
+
return ALL_RULES;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/transports/http.ts
|
|
633
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
634
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
635
|
+
|
|
636
|
+
// src/brand.ts
|
|
637
|
+
var BRAND = {
|
|
638
|
+
/** Display name of the product. */
|
|
639
|
+
name: "vexyo",
|
|
640
|
+
/** CLI command / binary name. */
|
|
641
|
+
bin: "vexyo"
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
// src/transports/http.ts
|
|
645
|
+
async function connectHttp(target) {
|
|
646
|
+
let url;
|
|
647
|
+
try {
|
|
648
|
+
url = new URL(target.url);
|
|
649
|
+
} catch (err) {
|
|
650
|
+
throw new Error(`Invalid HTTP target url: ${target.url}`, { cause: err });
|
|
651
|
+
}
|
|
652
|
+
const transport = new StreamableHTTPClientTransport(url, {
|
|
653
|
+
requestInit: target.headers ? { headers: target.headers } : void 0
|
|
654
|
+
});
|
|
655
|
+
const client = new Client(
|
|
656
|
+
{ name: `${BRAND.name}-probe`, version: "0.0.0" },
|
|
657
|
+
{ capabilities: {} }
|
|
658
|
+
);
|
|
659
|
+
try {
|
|
660
|
+
await client.connect(transport);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
await transport.close().catch(() => void 0);
|
|
663
|
+
throw new Error(
|
|
664
|
+
`Failed to connect to an MCP server over Streamable HTTP (url: ${target.url}). Check that the server is running and the URL points at its MCP endpoint.`,
|
|
665
|
+
{ cause: err }
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
client,
|
|
670
|
+
transport: { kind: "http", sessionId: transport.sessionId },
|
|
671
|
+
close: async () => {
|
|
672
|
+
await client.close();
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/transports/stdio.ts
|
|
678
|
+
import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
679
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
680
|
+
async function connectStdio(target) {
|
|
681
|
+
const transport = new StdioClientTransport({
|
|
682
|
+
command: target.command,
|
|
683
|
+
args: target.args,
|
|
684
|
+
cwd: target.cwd,
|
|
685
|
+
// When env is omitted the SDK supplies a safe default (PATH/HOME/etc.).
|
|
686
|
+
env: target.env,
|
|
687
|
+
stderr: "inherit"
|
|
688
|
+
});
|
|
689
|
+
const client = new Client2(
|
|
690
|
+
{ name: `${BRAND.name}-probe`, version: "0.0.0" },
|
|
691
|
+
{ capabilities: {} }
|
|
692
|
+
);
|
|
693
|
+
try {
|
|
694
|
+
await client.connect(transport);
|
|
695
|
+
} catch (err) {
|
|
696
|
+
await transport.close().catch(() => void 0);
|
|
697
|
+
throw new Error(
|
|
698
|
+
`Failed to connect to an MCP server over stdio (command: ${target.command}). Check that the command starts a stdio MCP server and exits cleanly on stdin close.`,
|
|
699
|
+
{ cause: err }
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
client,
|
|
704
|
+
transport: { kind: "stdio" },
|
|
705
|
+
close: async () => {
|
|
706
|
+
await client.close();
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/transports/index.ts
|
|
712
|
+
function connectTarget(target) {
|
|
713
|
+
return target.transport === "stdio" ? connectStdio(target.stdio) : connectHttp(target.http);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/runner.ts
|
|
717
|
+
function describeTarget(target) {
|
|
718
|
+
if (target.transport === "http") {
|
|
719
|
+
return { transport: "http", description: `http: ${target.http.url}` };
|
|
720
|
+
}
|
|
721
|
+
const { command, args } = target.stdio;
|
|
722
|
+
return { transport: "stdio", description: `stdio: ${command} ${(args ?? []).join(" ")}`.trim() };
|
|
723
|
+
}
|
|
724
|
+
var SEVERITY_RANK = { info: 1, warning: 2, error: 3 };
|
|
725
|
+
async function runSuite(opts) {
|
|
726
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
727
|
+
const failOn = opts.failOn ?? "error";
|
|
728
|
+
const rules = rulesForSpecVersion(opts.specVersion);
|
|
729
|
+
const target = describeTarget(opts.target);
|
|
730
|
+
const conn = await connectTarget(opts.target);
|
|
731
|
+
let results;
|
|
732
|
+
try {
|
|
733
|
+
const ctx = {
|
|
734
|
+
client: conn.client,
|
|
735
|
+
serverInfo: conn.client.getServerVersion(),
|
|
736
|
+
capabilities: conn.client.getServerCapabilities(),
|
|
737
|
+
transport: conn.transport,
|
|
738
|
+
specVersion: opts.specVersion
|
|
739
|
+
};
|
|
740
|
+
results = await runRules(ctx, rules);
|
|
741
|
+
if (opts.extraChecks) {
|
|
742
|
+
results.push(...await opts.extraChecks(ctx));
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
await conn.close().catch(() => void 0);
|
|
746
|
+
}
|
|
747
|
+
const finishedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
748
|
+
return {
|
|
749
|
+
specVersion: opts.specVersion,
|
|
750
|
+
target,
|
|
751
|
+
startedAt,
|
|
752
|
+
finishedAt,
|
|
753
|
+
results,
|
|
754
|
+
summary: summarizeResults(results),
|
|
755
|
+
exitCode: computeExitCode(results, failOn)
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async function runRules(ctx, rules) {
|
|
759
|
+
const results = [];
|
|
760
|
+
for (const rule of rules) {
|
|
761
|
+
results.push(await runOneRule(rule, ctx));
|
|
762
|
+
}
|
|
763
|
+
return results;
|
|
764
|
+
}
|
|
765
|
+
async function runOneRule(rule, ctx) {
|
|
766
|
+
const start = Date.now();
|
|
767
|
+
const base = {
|
|
768
|
+
ruleId: rule.id,
|
|
769
|
+
title: rule.title,
|
|
770
|
+
category: rule.category,
|
|
771
|
+
severity: rule.severity,
|
|
772
|
+
specVersion: rule.specVersion,
|
|
773
|
+
specRef: rule.specRef
|
|
774
|
+
};
|
|
775
|
+
try {
|
|
776
|
+
const findings = await rule.run(ctx);
|
|
777
|
+
return {
|
|
778
|
+
...base,
|
|
779
|
+
status: statusFromFindings(findings),
|
|
780
|
+
findings,
|
|
781
|
+
durationMs: Date.now() - start
|
|
782
|
+
};
|
|
783
|
+
} catch (err) {
|
|
784
|
+
if (err instanceof SkipRule) {
|
|
785
|
+
return {
|
|
786
|
+
...base,
|
|
787
|
+
status: "skip",
|
|
788
|
+
skipReason: err.reason,
|
|
789
|
+
findings: [],
|
|
790
|
+
durationMs: Date.now() - start
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
...base,
|
|
795
|
+
status: "error",
|
|
796
|
+
findings: [
|
|
797
|
+
{
|
|
798
|
+
ruleId: rule.id,
|
|
799
|
+
severity: rule.severity,
|
|
800
|
+
message: `Rule threw an unexpected error: ${cleanErrorMessage(err)}`,
|
|
801
|
+
remediation: "This usually means the server crashed mid-run or the rule has a bug. Re-run and inspect the server logs.",
|
|
802
|
+
specRef: rule.specRef,
|
|
803
|
+
detail: err instanceof Error ? { stack: err.stack } : err
|
|
804
|
+
}
|
|
805
|
+
],
|
|
806
|
+
durationMs: Date.now() - start
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function statusFromFindings(findings) {
|
|
811
|
+
if (findings.length === 0) {
|
|
812
|
+
return "pass";
|
|
813
|
+
}
|
|
814
|
+
return findings.some((f) => f.severity === "error") ? "fail" : "warn";
|
|
815
|
+
}
|
|
816
|
+
function summarizeResults(results) {
|
|
817
|
+
const summary = {
|
|
818
|
+
pass: 0,
|
|
819
|
+
fail: 0,
|
|
820
|
+
warn: 0,
|
|
821
|
+
skip: 0,
|
|
822
|
+
error: 0,
|
|
823
|
+
total: results.length
|
|
824
|
+
};
|
|
825
|
+
for (const result of results) {
|
|
826
|
+
summary[result.status] += 1;
|
|
827
|
+
}
|
|
828
|
+
return summary;
|
|
829
|
+
}
|
|
830
|
+
function computeExitCode(results, failOn) {
|
|
831
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
832
|
+
for (const result of results) {
|
|
833
|
+
if (result.status === "error") {
|
|
834
|
+
return 1;
|
|
835
|
+
}
|
|
836
|
+
for (const finding2 of result.findings) {
|
|
837
|
+
if (SEVERITY_RANK[finding2.severity] >= threshold) {
|
|
838
|
+
return 1;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return 0;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// src/golden/format.ts
|
|
846
|
+
import { z as z3 } from "zod";
|
|
847
|
+
var GOLDEN_FORMAT_VERSION = 1;
|
|
848
|
+
var jsonRecord = z3.record(z3.string(), z3.unknown());
|
|
849
|
+
var GoldenManifestSchema = z3.object({
|
|
850
|
+
formatVersion: z3.literal(GOLDEN_FORMAT_VERSION),
|
|
851
|
+
specVersion: z3.string(),
|
|
852
|
+
server: z3.object({ name: z3.string(), version: z3.string() }),
|
|
853
|
+
tools: z3.array(jsonRecord),
|
|
854
|
+
resources: z3.array(jsonRecord),
|
|
855
|
+
prompts: z3.array(jsonRecord)
|
|
856
|
+
});
|
|
857
|
+
var GoldenCaseSchema = z3.object({
|
|
858
|
+
case: z3.string(),
|
|
859
|
+
arguments: z3.record(z3.string(), z3.unknown()),
|
|
860
|
+
/** Names of the normalizers applied at record time (for human reference). */
|
|
861
|
+
normalizers: z3.array(z3.string()),
|
|
862
|
+
result: z3.unknown()
|
|
863
|
+
});
|
|
864
|
+
var GoldenRecordingSchema = z3.object({
|
|
865
|
+
formatVersion: z3.literal(GOLDEN_FORMAT_VERSION),
|
|
866
|
+
tool: z3.string(),
|
|
867
|
+
cases: z3.array(GoldenCaseSchema)
|
|
868
|
+
});
|
|
869
|
+
var FieldDiffSchema = z3.object({
|
|
870
|
+
path: z3.string(),
|
|
871
|
+
before: z3.unknown().optional(),
|
|
872
|
+
after: z3.unknown().optional()
|
|
873
|
+
});
|
|
874
|
+
var RegressionDetailSchema = z3.object({
|
|
875
|
+
kind: z3.enum(["schema", "behavioral", "coverage"]),
|
|
876
|
+
target: z3.object({
|
|
877
|
+
type: z3.enum(["tool", "resource", "prompt"]),
|
|
878
|
+
name: z3.string(),
|
|
879
|
+
case: z3.string().optional()
|
|
880
|
+
}),
|
|
881
|
+
change: z3.enum(["added", "removed", "changed"]),
|
|
882
|
+
before: z3.unknown().optional(),
|
|
883
|
+
after: z3.unknown().optional(),
|
|
884
|
+
fieldDiffs: z3.array(FieldDiffSchema).optional(),
|
|
885
|
+
/** Builtin normalizer names that would collapse a volatile-looking diff. */
|
|
886
|
+
suggestedNormalizers: z3.array(z3.string()).optional()
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// src/golden/normalize.ts
|
|
890
|
+
var STRING_PATTERNS = {
|
|
891
|
+
"iso-timestamp": {
|
|
892
|
+
regex: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g,
|
|
893
|
+
placeholder: "<timestamp>"
|
|
894
|
+
},
|
|
895
|
+
uuid: {
|
|
896
|
+
regex: /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g,
|
|
897
|
+
placeholder: "<uuid>"
|
|
898
|
+
},
|
|
899
|
+
"hex-id": { regex: /\b[0-9a-fA-F]{16,}\b/g, placeholder: "<hex>" },
|
|
900
|
+
"abs-path": { regex: /(?:\/[\w.-]+){2,}\/?/g, placeholder: "<path>" }
|
|
901
|
+
};
|
|
902
|
+
var NUMBER_RANGES = {
|
|
903
|
+
"epoch-millis": { min: 1e12, max: 2e12 },
|
|
904
|
+
"epoch-seconds": { min: 1e9, max: 2e9 }
|
|
905
|
+
};
|
|
906
|
+
var BUILTIN_NORMALIZER_NAMES = [
|
|
907
|
+
...Object.keys(STRING_PATTERNS),
|
|
908
|
+
...Object.keys(NUMBER_RANGES)
|
|
909
|
+
];
|
|
910
|
+
function builtin(name) {
|
|
911
|
+
const stringPattern = STRING_PATTERNS[name];
|
|
912
|
+
if (stringPattern) {
|
|
913
|
+
return (value) => mapScalars(
|
|
914
|
+
value,
|
|
915
|
+
(scalar) => typeof scalar === "string" ? scalar.replace(stringPattern.regex, stringPattern.placeholder) : scalar
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
const range = NUMBER_RANGES[name];
|
|
919
|
+
if (range) {
|
|
920
|
+
return (value) => mapScalars(
|
|
921
|
+
value,
|
|
922
|
+
(scalar) => typeof scalar === "number" && Number.isInteger(scalar) && scalar >= range.min && scalar < range.max ? "<epoch>" : scalar
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
throw new Error(
|
|
926
|
+
`Unknown normalizer "${name}". Available builtins: ${BUILTIN_NORMALIZER_NAMES.join(", ")}.`
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
function mapScalars(value, fn) {
|
|
930
|
+
if (Array.isArray(value)) {
|
|
931
|
+
return value.map((item) => mapScalars(item, fn));
|
|
932
|
+
}
|
|
933
|
+
if (value !== null && typeof value === "object") {
|
|
934
|
+
const record = value;
|
|
935
|
+
const out = {};
|
|
936
|
+
for (const key of Object.keys(record)) {
|
|
937
|
+
out[key] = mapScalars(record[key], fn);
|
|
938
|
+
}
|
|
939
|
+
return out;
|
|
940
|
+
}
|
|
941
|
+
return fn(value);
|
|
942
|
+
}
|
|
943
|
+
function resolve(ref) {
|
|
944
|
+
return typeof ref === "string" ? builtin(ref) : ref.apply;
|
|
945
|
+
}
|
|
946
|
+
function normalizerName(ref) {
|
|
947
|
+
return typeof ref === "string" ? ref : ref.name;
|
|
948
|
+
}
|
|
949
|
+
function applyNormalizers(value, refs) {
|
|
950
|
+
return refs.reduce((acc, ref) => resolve(ref)(acc), value);
|
|
951
|
+
}
|
|
952
|
+
function suggestNormalizer(before, after) {
|
|
953
|
+
const suggestions = [];
|
|
954
|
+
for (const [name, pattern] of Object.entries(STRING_PATTERNS)) {
|
|
955
|
+
if (typeof before === "string" && typeof after === "string" && matchesPattern(before, pattern.regex) && matchesPattern(after, pattern.regex)) {
|
|
956
|
+
suggestions.push(name);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
for (const [name, range] of Object.entries(NUMBER_RANGES)) {
|
|
960
|
+
if (inRange(before, range) && inRange(after, range)) {
|
|
961
|
+
suggestions.push(name);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return suggestions;
|
|
965
|
+
}
|
|
966
|
+
function matchesPattern(value, regex) {
|
|
967
|
+
return new RegExp(regex.source).test(value);
|
|
968
|
+
}
|
|
969
|
+
function inRange(value, range) {
|
|
970
|
+
return typeof value === "number" && Number.isInteger(value) && value >= range.min && value < range.max;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// src/golden/record.ts
|
|
974
|
+
async function recordGoldens(client, cfg) {
|
|
975
|
+
const manifest = await snapshotManifest(client, cfg.specVersion);
|
|
976
|
+
const recordings = [];
|
|
977
|
+
for (const tool of Object.keys(cfg.tools).sort()) {
|
|
978
|
+
const toolSpec = cfg.tools[tool];
|
|
979
|
+
if (!toolSpec) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
const refs = toolSpec.normalizers ?? cfg.defaultNormalizers;
|
|
983
|
+
const cases = [];
|
|
984
|
+
for (const spec of [...toolSpec.cases].sort((a, b) => compare(a.case, b.case))) {
|
|
985
|
+
const raw = await client.request(
|
|
986
|
+
{ method: "tools/call", params: { name: tool, arguments: spec.arguments } },
|
|
987
|
+
anyResult
|
|
988
|
+
);
|
|
989
|
+
cases.push({
|
|
990
|
+
case: spec.case,
|
|
991
|
+
arguments: spec.arguments,
|
|
992
|
+
normalizers: refs.map(normalizerName),
|
|
993
|
+
result: applyNormalizers(raw, refs)
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
recordings.push({ formatVersion: GOLDEN_FORMAT_VERSION, tool, cases });
|
|
997
|
+
}
|
|
998
|
+
return { manifest, recordings };
|
|
999
|
+
}
|
|
1000
|
+
async function snapshotManifest(client, specVersion) {
|
|
1001
|
+
const caps = client.getServerCapabilities();
|
|
1002
|
+
const info = client.getServerVersion();
|
|
1003
|
+
const tools = caps?.tools ? (await client.request({ method: "tools/list", params: {} }, toolsListResult)).tools : [];
|
|
1004
|
+
const resources = caps?.resources ? (await client.request({ method: "resources/list", params: {} }, resourcesListResult)).resources : [];
|
|
1005
|
+
const prompts = caps?.prompts ? (await client.request({ method: "prompts/list", params: {} }, promptsListResult)).prompts : [];
|
|
1006
|
+
return {
|
|
1007
|
+
formatVersion: GOLDEN_FORMAT_VERSION,
|
|
1008
|
+
specVersion,
|
|
1009
|
+
server: { name: info?.name ?? "", version: info?.version ?? "" },
|
|
1010
|
+
tools: sortBy(tools, "name"),
|
|
1011
|
+
resources: sortBy(resources, "uri"),
|
|
1012
|
+
prompts: sortBy(prompts, "name")
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
function sortBy(items, key) {
|
|
1016
|
+
return [...items].sort((a, b) => compare(stringField(a, key) ?? "", stringField(b, key) ?? ""));
|
|
1017
|
+
}
|
|
1018
|
+
function compare(a, b) {
|
|
1019
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// src/golden/serialize.ts
|
|
1023
|
+
var PRINT_WIDTH = 100;
|
|
1024
|
+
var INDENT = " ";
|
|
1025
|
+
function isPlainObject(value) {
|
|
1026
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1027
|
+
}
|
|
1028
|
+
function canonicalize(value) {
|
|
1029
|
+
if (Array.isArray(value)) {
|
|
1030
|
+
return value.map((item) => canonicalize(item === void 0 ? null : item));
|
|
1031
|
+
}
|
|
1032
|
+
if (isPlainObject(value)) {
|
|
1033
|
+
const out = {};
|
|
1034
|
+
for (const key of Object.keys(value).sort()) {
|
|
1035
|
+
const child = value[key];
|
|
1036
|
+
if (child !== void 0 && typeof child !== "function") {
|
|
1037
|
+
out[key] = canonicalize(child);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
return out;
|
|
1041
|
+
}
|
|
1042
|
+
return typeof value === "function" ? null : value;
|
|
1043
|
+
}
|
|
1044
|
+
function flat(value) {
|
|
1045
|
+
if (Array.isArray(value)) {
|
|
1046
|
+
return value.length === 0 ? "[]" : `[${value.map(flat).join(", ")}]`;
|
|
1047
|
+
}
|
|
1048
|
+
if (isPlainObject(value)) {
|
|
1049
|
+
const keys = Object.keys(value);
|
|
1050
|
+
if (keys.length === 0) {
|
|
1051
|
+
return "{}";
|
|
1052
|
+
}
|
|
1053
|
+
return `{ ${keys.map((k) => `${JSON.stringify(k)}: ${flat(value[k])}`).join(", ")} }`;
|
|
1054
|
+
}
|
|
1055
|
+
return JSON.stringify(value);
|
|
1056
|
+
}
|
|
1057
|
+
function print(value, level, column, trailer) {
|
|
1058
|
+
if (!Array.isArray(value) && !isPlainObject(value)) {
|
|
1059
|
+
return JSON.stringify(value);
|
|
1060
|
+
}
|
|
1061
|
+
const flatForm = flat(value);
|
|
1062
|
+
const mustBreak = Array.isArray(value) && forcesBreak(value);
|
|
1063
|
+
if (!mustBreak && column + flatForm.length + trailer <= PRINT_WIDTH) {
|
|
1064
|
+
return flatForm;
|
|
1065
|
+
}
|
|
1066
|
+
const childIndent = INDENT.repeat(level + 1);
|
|
1067
|
+
const closeIndent = INDENT.repeat(level);
|
|
1068
|
+
if (Array.isArray(value)) {
|
|
1069
|
+
if (value.every((item) => typeof item === "number")) {
|
|
1070
|
+
return fillNumbers(value, level);
|
|
1071
|
+
}
|
|
1072
|
+
const items = value.map(
|
|
1073
|
+
(item, i) => print(item, level + 1, childIndent.length, i < value.length - 1 ? 1 : 0)
|
|
1074
|
+
);
|
|
1075
|
+
return `[
|
|
1076
|
+
${items.map((s) => childIndent + s).join(",\n")}
|
|
1077
|
+
${closeIndent}]`;
|
|
1078
|
+
}
|
|
1079
|
+
const keys = Object.keys(value);
|
|
1080
|
+
const props = keys.map((key, i) => {
|
|
1081
|
+
const prefix = `${JSON.stringify(key)}: `;
|
|
1082
|
+
const rendered = print(
|
|
1083
|
+
value[key],
|
|
1084
|
+
level + 1,
|
|
1085
|
+
childIndent.length + prefix.length,
|
|
1086
|
+
i < keys.length - 1 ? 1 : 0
|
|
1087
|
+
);
|
|
1088
|
+
return `${childIndent}${prefix}${rendered}`;
|
|
1089
|
+
});
|
|
1090
|
+
return `{
|
|
1091
|
+
${props.join(",\n")}
|
|
1092
|
+
${closeIndent}}`;
|
|
1093
|
+
}
|
|
1094
|
+
function forcesBreak(value) {
|
|
1095
|
+
return value.length > 1 && value.every(
|
|
1096
|
+
(el) => Array.isArray(el) && el.length > 1 || isPlainObject(el) && Object.keys(el).length > 1
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
function fillNumbers(value, level) {
|
|
1100
|
+
const childIndent = INDENT.repeat(level + 1);
|
|
1101
|
+
const lines = [];
|
|
1102
|
+
let current = "";
|
|
1103
|
+
value.forEach((num, i) => {
|
|
1104
|
+
const token = i < value.length - 1 ? `${JSON.stringify(num)},` : JSON.stringify(num);
|
|
1105
|
+
if (current === "") {
|
|
1106
|
+
current = token;
|
|
1107
|
+
} else if (childIndent.length + current.length + 1 + token.length <= PRINT_WIDTH) {
|
|
1108
|
+
current = `${current} ${token}`;
|
|
1109
|
+
} else {
|
|
1110
|
+
lines.push(current);
|
|
1111
|
+
current = token;
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
if (current !== "") {
|
|
1115
|
+
lines.push(current);
|
|
1116
|
+
}
|
|
1117
|
+
return `[
|
|
1118
|
+
${lines.map((l) => childIndent + l).join("\n")}
|
|
1119
|
+
${INDENT.repeat(level)}]`;
|
|
1120
|
+
}
|
|
1121
|
+
function stableStringify(value) {
|
|
1122
|
+
return `${print(canonicalize(value), 0, 0, 0)}
|
|
1123
|
+
`;
|
|
1124
|
+
}
|
|
1125
|
+
function jsonEqual(a, b) {
|
|
1126
|
+
return stableStringify(a) === stableStringify(b);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/golden/diff.ts
|
|
1130
|
+
function diffValue(before, after, path = "") {
|
|
1131
|
+
if (jsonEqual(before, after)) {
|
|
1132
|
+
return [];
|
|
1133
|
+
}
|
|
1134
|
+
if (isRecord(before) && isRecord(after)) {
|
|
1135
|
+
const keys = [.../* @__PURE__ */ new Set([...Object.keys(before), ...Object.keys(after)])].sort();
|
|
1136
|
+
return keys.flatMap((key) => diffValue(before[key], after[key], join(path, key)));
|
|
1137
|
+
}
|
|
1138
|
+
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
|
|
1139
|
+
return before.flatMap((item, i) => diffValue(item, after[i], `${path}[${i}]`));
|
|
1140
|
+
}
|
|
1141
|
+
return [{ path: path || "(root)", before, after }];
|
|
1142
|
+
}
|
|
1143
|
+
function join(path, key) {
|
|
1144
|
+
return path ? `${path}.${key}` : key;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/golden/regress.ts
|
|
1148
|
+
var SPEC_REF = "Regression / Golden set";
|
|
1149
|
+
async function runRegression(client, golden, cfg) {
|
|
1150
|
+
const specVersion = cfg.specVersion;
|
|
1151
|
+
const live = await snapshotLive(client);
|
|
1152
|
+
const results = [];
|
|
1153
|
+
results.push(...manifestDrift(specVersion, "tool", "name", golden.manifest.tools, live.tools));
|
|
1154
|
+
results.push(
|
|
1155
|
+
...manifestDrift(specVersion, "resource", "uri", golden.manifest.resources, live.resources)
|
|
1156
|
+
);
|
|
1157
|
+
results.push(
|
|
1158
|
+
...manifestDrift(specVersion, "prompt", "name", golden.manifest.prompts, live.prompts)
|
|
1159
|
+
);
|
|
1160
|
+
const liveToolNames = new Set(
|
|
1161
|
+
live.tools.map((t) => stringField(t, "name")).filter((n) => !!n)
|
|
1162
|
+
);
|
|
1163
|
+
for (const recording of golden.recordings) {
|
|
1164
|
+
if (!liveToolNames.has(recording.tool)) {
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
const refs = cfg.tools[recording.tool]?.normalizers ?? cfg.defaultNormalizers;
|
|
1168
|
+
for (const testCase of recording.cases) {
|
|
1169
|
+
results.push(await behavioralDrift(client, specVersion, recording.tool, testCase, refs));
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
return results;
|
|
1173
|
+
}
|
|
1174
|
+
function manifestDrift(specVersion, type, keyField, goldenDefs, liveDefs) {
|
|
1175
|
+
const results = [];
|
|
1176
|
+
const liveByKey = /* @__PURE__ */ new Map();
|
|
1177
|
+
for (const def of liveDefs) {
|
|
1178
|
+
const key = stringField(def, keyField);
|
|
1179
|
+
if (key !== void 0) {
|
|
1180
|
+
liveByKey.set(key, def);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
const goldenKeys = /* @__PURE__ */ new Set();
|
|
1184
|
+
for (const def of goldenDefs) {
|
|
1185
|
+
const key = stringField(def, keyField);
|
|
1186
|
+
if (key === void 0) {
|
|
1187
|
+
continue;
|
|
1188
|
+
}
|
|
1189
|
+
goldenKeys.add(key);
|
|
1190
|
+
const liveDef = liveByKey.get(key);
|
|
1191
|
+
if (!liveDef) {
|
|
1192
|
+
results.push(
|
|
1193
|
+
drift(specVersion, "coverage", "error", {
|
|
1194
|
+
detail: { kind: "coverage", target: { type, name: key }, change: "removed", before: def },
|
|
1195
|
+
message: `${type} "${key}" has a golden but no longer exists on the server.`,
|
|
1196
|
+
remediation: "Restore the target, or remove its golden if the removal is intended, then re-record."
|
|
1197
|
+
})
|
|
1198
|
+
);
|
|
1199
|
+
} else if (!jsonEqual(def, liveDef)) {
|
|
1200
|
+
results.push(
|
|
1201
|
+
drift(specVersion, "schema", "error", {
|
|
1202
|
+
detail: {
|
|
1203
|
+
kind: "schema",
|
|
1204
|
+
target: { type, name: key },
|
|
1205
|
+
change: "changed",
|
|
1206
|
+
before: def,
|
|
1207
|
+
after: liveDef,
|
|
1208
|
+
fieldDiffs: diffValue(def, liveDef)
|
|
1209
|
+
},
|
|
1210
|
+
message: `${type} "${key}" definition changed since the golden was recorded.`,
|
|
1211
|
+
remediation: "Review the change; if intended, re-record with `vexyo record`."
|
|
1212
|
+
})
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
for (const def of liveDefs) {
|
|
1217
|
+
const key = stringField(def, keyField);
|
|
1218
|
+
if (key === void 0 || goldenKeys.has(key)) {
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
results.push(
|
|
1222
|
+
drift(specVersion, "coverage", "warning", {
|
|
1223
|
+
detail: { kind: "coverage", target: { type, name: key }, change: "added", after: def },
|
|
1224
|
+
message: `${type} "${key}" exists on the server but has no golden.`,
|
|
1225
|
+
remediation: "Run `vexyo record` to capture it into the golden set."
|
|
1226
|
+
})
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
return results;
|
|
1230
|
+
}
|
|
1231
|
+
async function behavioralDrift(client, specVersion, tool, testCase, refs) {
|
|
1232
|
+
let liveNormalized;
|
|
1233
|
+
try {
|
|
1234
|
+
const raw = await client.request(
|
|
1235
|
+
{ method: "tools/call", params: { name: tool, arguments: testCase.arguments } },
|
|
1236
|
+
anyResult
|
|
1237
|
+
);
|
|
1238
|
+
liveNormalized = applyNormalizers(raw, refs);
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
liveNormalized = { error: err instanceof Error ? err.message : String(err) };
|
|
1241
|
+
}
|
|
1242
|
+
const fieldDiffs = diffValue(testCase.result, liveNormalized);
|
|
1243
|
+
if (fieldDiffs.length === 0) {
|
|
1244
|
+
return passResult(specVersion, tool, testCase.case);
|
|
1245
|
+
}
|
|
1246
|
+
const suggested = [...new Set(fieldDiffs.flatMap((d) => suggestNormalizer(d.before, d.after)))];
|
|
1247
|
+
const remediation = suggested.length > 0 ? `Field(s) look volatile (${suggested.join(", ")}); add the normalizer(s) for "${tool}", or re-record if the change is real.` : "If the change is intended, re-record with `vexyo record`.";
|
|
1248
|
+
return drift(specVersion, "behavioral", "error", {
|
|
1249
|
+
detail: {
|
|
1250
|
+
kind: "behavioral",
|
|
1251
|
+
target: { type: "tool", name: tool, case: testCase.case },
|
|
1252
|
+
change: "changed",
|
|
1253
|
+
before: testCase.result,
|
|
1254
|
+
after: liveNormalized,
|
|
1255
|
+
fieldDiffs,
|
|
1256
|
+
suggestedNormalizers: suggested.length > 0 ? suggested : void 0
|
|
1257
|
+
},
|
|
1258
|
+
message: `Tool "${tool}" (case "${testCase.case}") output differs from its golden.`,
|
|
1259
|
+
remediation
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
function drift(specVersion, kind, severity, spec) {
|
|
1263
|
+
const { target } = spec.detail;
|
|
1264
|
+
const suffix = target.case ? `${target.name}:${target.case}` : target.name;
|
|
1265
|
+
const ruleId = `regression/${kind}-drift:${target.type}:${suffix}`;
|
|
1266
|
+
const finding2 = {
|
|
1267
|
+
ruleId,
|
|
1268
|
+
severity,
|
|
1269
|
+
message: spec.message,
|
|
1270
|
+
remediation: spec.remediation,
|
|
1271
|
+
specRef: SPEC_REF,
|
|
1272
|
+
detail: spec.detail
|
|
1273
|
+
};
|
|
1274
|
+
return {
|
|
1275
|
+
ruleId,
|
|
1276
|
+
title: `${kind} drift \u2014 ${target.type} ${target.name}`,
|
|
1277
|
+
category: "regression",
|
|
1278
|
+
severity,
|
|
1279
|
+
specVersion,
|
|
1280
|
+
specRef: SPEC_REF,
|
|
1281
|
+
status: severity === "error" ? "fail" : "warn",
|
|
1282
|
+
findings: [finding2],
|
|
1283
|
+
durationMs: 0
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
function passResult(specVersion, tool, testCase) {
|
|
1287
|
+
return {
|
|
1288
|
+
ruleId: `regression/behavioral:${tool}:${testCase}`,
|
|
1289
|
+
title: `behavioral \u2014 tool ${tool} (case ${testCase})`,
|
|
1290
|
+
category: "regression",
|
|
1291
|
+
severity: "info",
|
|
1292
|
+
specVersion,
|
|
1293
|
+
specRef: SPEC_REF,
|
|
1294
|
+
status: "pass",
|
|
1295
|
+
findings: [],
|
|
1296
|
+
durationMs: 0
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
async function snapshotLive(client) {
|
|
1300
|
+
const caps = client.getServerCapabilities();
|
|
1301
|
+
const tools = caps?.tools ? (await client.request({ method: "tools/list", params: {} }, toolsListResult)).tools : [];
|
|
1302
|
+
const resources = caps?.resources ? (await client.request({ method: "resources/list", params: {} }, resourcesListResult)).resources : [];
|
|
1303
|
+
const prompts = caps?.prompts ? (await client.request({ method: "prompts/list", params: {} }, promptsListResult)).prompts : [];
|
|
1304
|
+
return { tools, resources, prompts };
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// src/golden/io.ts
|
|
1308
|
+
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
|
1309
|
+
import { join as join2 } from "path";
|
|
1310
|
+
import "zod";
|
|
1311
|
+
var GoldenError = class extends Error {
|
|
1312
|
+
constructor(message, cause) {
|
|
1313
|
+
super(message, cause !== void 0 ? { cause } : void 0);
|
|
1314
|
+
this.name = "GoldenError";
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
async function writeGoldenSet(dir, set) {
|
|
1318
|
+
await mkdir(join2(dir, "recordings"), { recursive: true });
|
|
1319
|
+
const written = [];
|
|
1320
|
+
const manifestPath = join2(dir, "manifest.json");
|
|
1321
|
+
await writeFile(manifestPath, stableStringify(set.manifest));
|
|
1322
|
+
written.push(manifestPath);
|
|
1323
|
+
for (const recording of [...set.recordings].sort((a, b) => a.tool < b.tool ? -1 : 1)) {
|
|
1324
|
+
const path = join2(dir, "recordings", `${recording.tool}.json`);
|
|
1325
|
+
await writeFile(path, stableStringify(recording));
|
|
1326
|
+
written.push(path);
|
|
1327
|
+
}
|
|
1328
|
+
return written;
|
|
1329
|
+
}
|
|
1330
|
+
async function readGoldenSet(dir) {
|
|
1331
|
+
const manifestPath = join2(dir, "manifest.json");
|
|
1332
|
+
let manifestRaw;
|
|
1333
|
+
try {
|
|
1334
|
+
manifestRaw = await readFile(manifestPath, "utf8");
|
|
1335
|
+
} catch (err) {
|
|
1336
|
+
throw new GoldenError(
|
|
1337
|
+
`No golden manifest at ${manifestPath}. Run \`vexyo record\` to create one.`,
|
|
1338
|
+
err
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1341
|
+
const manifest = parseGolden(GoldenManifestSchema, manifestRaw, manifestPath);
|
|
1342
|
+
const recordings = [];
|
|
1343
|
+
const recordingsDir = join2(dir, "recordings");
|
|
1344
|
+
let files = [];
|
|
1345
|
+
try {
|
|
1346
|
+
files = (await readdir(recordingsDir)).filter((f) => f.endsWith(".json")).sort();
|
|
1347
|
+
} catch {
|
|
1348
|
+
files = [];
|
|
1349
|
+
}
|
|
1350
|
+
for (const file of files) {
|
|
1351
|
+
const path = join2(recordingsDir, file);
|
|
1352
|
+
recordings.push(parseGolden(GoldenRecordingSchema, await readFile(path, "utf8"), path));
|
|
1353
|
+
}
|
|
1354
|
+
return { manifest, recordings };
|
|
1355
|
+
}
|
|
1356
|
+
function parseGolden(schema, raw, path) {
|
|
1357
|
+
let json;
|
|
1358
|
+
try {
|
|
1359
|
+
json = JSON.parse(raw);
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
throw new GoldenError(`Golden at ${path} is not valid JSON.`, err);
|
|
1362
|
+
}
|
|
1363
|
+
const parsed = schema.safeParse(json);
|
|
1364
|
+
if (parsed.success) {
|
|
1365
|
+
return parsed.data;
|
|
1366
|
+
}
|
|
1367
|
+
const version = isRecord(json) ? json["formatVersion"] : void 0;
|
|
1368
|
+
if (version !== void 0 && version !== GOLDEN_FORMAT_VERSION) {
|
|
1369
|
+
throw new GoldenError(
|
|
1370
|
+
`Golden at ${path} has formatVersion ${String(version)}, but this build expects ${GOLDEN_FORMAT_VERSION}. Re-run \`vexyo record\` or upgrade vexyo.`
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
1373
|
+
const issues = parsed.error.issues.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`).join("\n");
|
|
1374
|
+
throw new GoldenError(`Golden at ${path} is malformed:
|
|
1375
|
+
${issues}`);
|
|
1376
|
+
}
|
|
1377
|
+
export {
|
|
1378
|
+
BRAND,
|
|
1379
|
+
BUILTIN_NORMALIZER_NAMES,
|
|
1380
|
+
GOLDEN_FORMAT_VERSION,
|
|
1381
|
+
GoldenError,
|
|
1382
|
+
GoldenManifestSchema,
|
|
1383
|
+
GoldenRecordingSchema,
|
|
1384
|
+
RegressionDetailSchema,
|
|
1385
|
+
STABLE_SPEC_VERSION,
|
|
1386
|
+
SkipRule,
|
|
1387
|
+
allRules,
|
|
1388
|
+
applyNormalizers,
|
|
1389
|
+
computeExitCode,
|
|
1390
|
+
connectHttp,
|
|
1391
|
+
connectStdio,
|
|
1392
|
+
connectTarget,
|
|
1393
|
+
finding,
|
|
1394
|
+
normalizerName,
|
|
1395
|
+
readGoldenSet,
|
|
1396
|
+
recordGoldens,
|
|
1397
|
+
rulesForSpecVersion,
|
|
1398
|
+
runRegression,
|
|
1399
|
+
runRules,
|
|
1400
|
+
runSuite,
|
|
1401
|
+
stableStringify,
|
|
1402
|
+
suggestNormalizer,
|
|
1403
|
+
summarizeResults,
|
|
1404
|
+
writeGoldenSet
|
|
1405
|
+
};
|
|
1406
|
+
//# sourceMappingURL=index.js.map
|