modelfunction-cli 0.3.0 → 0.4.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/README.md +1 -1
- package/lib/js-tools.js +954 -0
- package/lib/mcp-server.js +1 -0
- package/lib/scan.js +76 -12
- package/lib/suite.js +2 -2
- package/mf.js +10 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
|
|
|
64
64
|
|
|
65
65
|
| 命令 | 作用 |
|
|
66
66
|
| --- | --- |
|
|
67
|
-
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool
|
|
67
|
+
| `mf scan` | 扫描工具定义(function.json / OpenAPI / MCP 配置 / Python `@tool` / JS·TS 声明与 TypeBox·zod schema);`--probe` 连 MCP server(运行时结果覆盖静态推断);`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
|
|
68
68
|
| `mf eval` | 跑评分卡;`--repeat` 多次取多数并报不稳定用例;`--fail-under` 阈值;`--baseline` + `--fail-on-regression` 回归门禁;`--profile` 按挂载组分别评分 |
|
|
69
69
|
| `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
|
|
70
70
|
| `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
|
package/lib/js-tools.js
ADDED
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
// Static extraction of tool definitions from JS/TS source. This never executes
|
|
2
|
+
// the file: it parses the subset of expressions that real registries use —
|
|
3
|
+
// object/array literals, `const` references to those literals, and the TypeBox
|
|
4
|
+
// (`Type.Object`) / zod (`z.object`) builders. Anything else is reported as
|
|
5
|
+
// unresolved instead of being guessed, because a wrong schema is worse than a
|
|
6
|
+
// missing tool.
|
|
7
|
+
const MAX_VALUE_DEPTH = 24;
|
|
8
|
+
const MAX_RESOLVE_DEPTH = 8;
|
|
9
|
+
const MAX_ARRAY_ATTEMPTS = 300;
|
|
10
|
+
|
|
11
|
+
const PREFILTER = /inputSchema|parameters\s*:|registerTool|\.tool\s*\(|buildTool|defineTool|createTool|Type\.Object|[tz]\.object/;
|
|
12
|
+
const TOOL_BUILDERS = new Set(["buildTool", "defineTool", "createTool", "makeTool"]);
|
|
13
|
+
// `async () => {}` and `function () {}` otherwise parse as a call named
|
|
14
|
+
// "async"/"function" and swallow the rest of the argument list.
|
|
15
|
+
const RESERVED = new Set([
|
|
16
|
+
"async", "await", "function", "new", "return", "if", "else", "for", "while",
|
|
17
|
+
"do", "switch", "case", "try", "catch", "finally", "throw", "typeof", "void",
|
|
18
|
+
"delete", "in", "of", "instanceof", "class", "const", "let", "var", "export",
|
|
19
|
+
"import", "yield", "this", "super"
|
|
20
|
+
]);
|
|
21
|
+
const SCHEMA_KEYS = ["inputSchema", "parameters", "input", "parametersSchema", "schema"];
|
|
22
|
+
|
|
23
|
+
export function extractJsTools(src, { externals, onUnresolvedName } = {}) {
|
|
24
|
+
if (typeof src !== "string" || !PREFILTER.test(src)) return [];
|
|
25
|
+
const ctx = { src, bindings: collectBindings(src), parsed: new Map(), externals, onUnresolvedName };
|
|
26
|
+
const tools = [];
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const push = (tool) => {
|
|
29
|
+
if (!tool || !tool.name || seen.has(tool.name)) return;
|
|
30
|
+
seen.add(tool.name);
|
|
31
|
+
tools.push(tool);
|
|
32
|
+
};
|
|
33
|
+
let attempts = 0;
|
|
34
|
+
|
|
35
|
+
for (const match of src.matchAll(/([A-Za-z_$][\w$]*)\.(tool|registerTool)\s*\(/g)) {
|
|
36
|
+
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
37
|
+
if (parsed) push(toolFromArgs(parsed.items, ctx));
|
|
38
|
+
}
|
|
39
|
+
for (const match of src.matchAll(/(?<![.\w$])registerTool\s*\(/g)) {
|
|
40
|
+
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
41
|
+
if (parsed) push(toolFromArgs(parsed.items, ctx));
|
|
42
|
+
}
|
|
43
|
+
for (const match of src.matchAll(/(?<![.\w$])([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
44
|
+
if (!TOOL_BUILDERS.has(match[1])) continue;
|
|
45
|
+
const parsed = parseArgs(src, match.index + match[0].length - 1);
|
|
46
|
+
if (!parsed || !parsed.items[0] || parsed.items[0].t !== "obj") continue;
|
|
47
|
+
push(toolFromObject(parsed.items[0], ctx));
|
|
48
|
+
}
|
|
49
|
+
for (const match of src.matchAll(/(?:export\s+)?(?:const|let|var)\s+[A-Za-z_$][\w$]*\s*(?::[^=\n]{0,200})?=\s*\{/g)) {
|
|
50
|
+
const parsed = parseValue(src, match.index + match[0].length - 1, 0);
|
|
51
|
+
if (parsed && parsed.node.t === "obj") push(toolFromObject(parsed.node, ctx));
|
|
52
|
+
}
|
|
53
|
+
for (const match of src.matchAll(/\[\s*\{/g)) {
|
|
54
|
+
if (attempts >= MAX_ARRAY_ATTEMPTS) break;
|
|
55
|
+
attempts += 1;
|
|
56
|
+
const parsed = parseValue(src, match.index, 0);
|
|
57
|
+
if (!parsed || parsed.node.t !== "arr") continue;
|
|
58
|
+
for (const item of parsed.node.items) {
|
|
59
|
+
if (item && item.t === "obj") push(toolFromObject(item, ctx));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return tools;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toolFromArgs(args, ctx) {
|
|
66
|
+
const name = stringOf(args[0], ctx);
|
|
67
|
+
if (!name) return null;
|
|
68
|
+
let description = "";
|
|
69
|
+
let parameters = null;
|
|
70
|
+
for (const arg of args.slice(1)) {
|
|
71
|
+
if (!arg) continue;
|
|
72
|
+
if (arg.t === "str") {
|
|
73
|
+
if (!description) description = arg.value;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (arg.t !== "obj") continue;
|
|
77
|
+
const fromObject = toolFromObject(arg, ctx, { name, description });
|
|
78
|
+
if (fromObject) return fromObject;
|
|
79
|
+
}
|
|
80
|
+
for (const arg of args.slice(1)) {
|
|
81
|
+
if (!arg) continue;
|
|
82
|
+
const { schema, resolved } = schemaOf(arg, ctx);
|
|
83
|
+
if (!resolved || !isSchema(schema)) continue;
|
|
84
|
+
parameters = schema;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
if (!parameters) return null;
|
|
88
|
+
return { name, description, parameters };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function toolFromObject(node, ctx, preset = {}) {
|
|
92
|
+
const name = preset.name ?? stringOf(node.props.get("name"), ctx) ?? stringOf(node.props.get("toolName"), ctx);
|
|
93
|
+
if (!name) return undefined;
|
|
94
|
+
const description =
|
|
95
|
+
stringOf(node.props.get("description"), ctx) ??
|
|
96
|
+
stringOf(node.props.get("summary"), ctx) ??
|
|
97
|
+
preset.description ??
|
|
98
|
+
"";
|
|
99
|
+
let schemaNode = null;
|
|
100
|
+
for (const key of SCHEMA_KEYS) {
|
|
101
|
+
if (node.props.has(key)) {
|
|
102
|
+
schemaNode = node.props.get(key);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (!schemaNode) return undefined;
|
|
107
|
+
const { schema, resolved } = schemaOf(schemaNode, ctx);
|
|
108
|
+
if (!resolved || !isSchema(schema)) return undefined;
|
|
109
|
+
// An empty schema with no description and no tool behaviour is a config
|
|
110
|
+
// object that happens to be shaped like a tool (`{ name: "mcp" }`).
|
|
111
|
+
const toolish =
|
|
112
|
+
node.props.has("call") ||
|
|
113
|
+
node.props.has("prompt") ||
|
|
114
|
+
node.props.has("userFacingName") ||
|
|
115
|
+
node.props.has("isEnabled") ||
|
|
116
|
+
node.props.has("searchHint");
|
|
117
|
+
if (!description && Object.keys(schema.properties ?? {}).length === 0 && !toolish) return undefined;
|
|
118
|
+
return { name, description, parameters: schema };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function isSchema(value) {
|
|
122
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function stringOf(node, ctx) {
|
|
126
|
+
if (!node) return null;
|
|
127
|
+
if (node.t === "str") return node.value;
|
|
128
|
+
if (node.t === "ident") {
|
|
129
|
+
// `name: CONFIG_TOOL_NAME` — a local const, or a repo-wide uppercase
|
|
130
|
+
// constant indexed by the scanner across files.
|
|
131
|
+
const local = resolveIdent(node.name, ctx);
|
|
132
|
+
if (local && local.t === "str") return local.value;
|
|
133
|
+
const external = ctx?.externals?.get(node.name);
|
|
134
|
+
if (typeof external === "string") return external;
|
|
135
|
+
ctx?.onUnresolvedName?.(node.name);
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
if (node.t === "obj") {
|
|
139
|
+
const value = node.props.get("value");
|
|
140
|
+
if (value && value.t === "str") return value.value;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- schema construction -------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function schemaOf(node, ctx, depth = 0) {
|
|
148
|
+
if (!node || depth > MAX_VALUE_DEPTH) return { schema: {}, resolved: false };
|
|
149
|
+
if (node.t === "ident") {
|
|
150
|
+
const resolvedNode = resolveIdent(node.name, ctx, depth);
|
|
151
|
+
if (!resolvedNode) return { schema: {}, resolved: false };
|
|
152
|
+
return schemaOf(resolvedNode, ctx, depth + 1);
|
|
153
|
+
}
|
|
154
|
+
if (node.t === "obj") {
|
|
155
|
+
if (node.props.has("type") || node.props.has("properties") || node.props.has("items")) {
|
|
156
|
+
return { schema: jsonSchemaLiteral(node, ctx, depth), resolved: true };
|
|
157
|
+
}
|
|
158
|
+
// MCP SDK shape argument: server.tool("x", "desc", { query: z.string() }).
|
|
159
|
+
const rawShape = rawShapeSchema(node, ctx, depth);
|
|
160
|
+
return rawShape ? { schema: rawShape, resolved: true } : { schema: {}, resolved: false };
|
|
161
|
+
}
|
|
162
|
+
if (node.t !== "call") return { schema: {}, resolved: false };
|
|
163
|
+
|
|
164
|
+
const args = node.args ?? [];
|
|
165
|
+
const callee = node.callee;
|
|
166
|
+
let schema = null;
|
|
167
|
+
let optional = false;
|
|
168
|
+
|
|
169
|
+
switch (callee) {
|
|
170
|
+
case "Type.Object":
|
|
171
|
+
case "t.Object":
|
|
172
|
+
case "z.object":
|
|
173
|
+
case "z.strictObject":
|
|
174
|
+
case "z.looseObject":
|
|
175
|
+
schema = objectSchema(args[0], ctx, depth);
|
|
176
|
+
break;
|
|
177
|
+
case "Type.String":
|
|
178
|
+
case "t.String":
|
|
179
|
+
case "z.string":
|
|
180
|
+
schema = { type: "string" };
|
|
181
|
+
break;
|
|
182
|
+
case "Type.Number":
|
|
183
|
+
case "t.Number":
|
|
184
|
+
case "z.number":
|
|
185
|
+
schema = { type: "number" };
|
|
186
|
+
break;
|
|
187
|
+
case "Type.Integer":
|
|
188
|
+
case "t.Integer":
|
|
189
|
+
case "z.int":
|
|
190
|
+
schema = { type: "integer" };
|
|
191
|
+
break;
|
|
192
|
+
case "z.coerce.number":
|
|
193
|
+
schema = { type: "number" };
|
|
194
|
+
break;
|
|
195
|
+
case "z.coerce.string":
|
|
196
|
+
schema = { type: "string" };
|
|
197
|
+
break;
|
|
198
|
+
case "z.coerce.boolean":
|
|
199
|
+
schema = { type: "boolean" };
|
|
200
|
+
break;
|
|
201
|
+
case "Type.Boolean":
|
|
202
|
+
case "t.Boolean":
|
|
203
|
+
case "z.boolean":
|
|
204
|
+
schema = { type: "boolean" };
|
|
205
|
+
break;
|
|
206
|
+
case "Type.Array":
|
|
207
|
+
case "t.Array":
|
|
208
|
+
case "z.array": {
|
|
209
|
+
const item = schemaOf(args[0], ctx, depth + 1);
|
|
210
|
+
schema = { type: "array", items: item.schema };
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case "Type.Optional":
|
|
214
|
+
case "z.optional": {
|
|
215
|
+
const inner = schemaOf(args[0], ctx, depth + 1);
|
|
216
|
+
return { ...inner, optional: true };
|
|
217
|
+
}
|
|
218
|
+
case "Type.Literal":
|
|
219
|
+
case "t.Literal":
|
|
220
|
+
case "z.literal": {
|
|
221
|
+
const value = plainOf(args[0]);
|
|
222
|
+
schema = value === undefined ? {} : { enum: [value] };
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
case "z.enum": {
|
|
226
|
+
const values = plainOf(args[0]);
|
|
227
|
+
schema = Array.isArray(values) ? { type: "string", enum: values } : {};
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
case "Type.Union":
|
|
231
|
+
case "t.Union":
|
|
232
|
+
case "z.union": {
|
|
233
|
+
const nodes = args.length === 1 && args[0] && args[0].t === "arr" ? args[0].items : args;
|
|
234
|
+
const literals = nodes.map((item) => plainOf(item));
|
|
235
|
+
if (literals.length > 0 && literals.every((item) => item !== undefined)) {
|
|
236
|
+
schema = { enum: literals };
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
// z.union([z.string(), z.boolean()]) — keep the types we can name.
|
|
240
|
+
const types = [
|
|
241
|
+
...new Set(nodes.map((item) => schemaOf(item, ctx, depth + 1).schema?.type).filter((t) => typeof t === "string"))
|
|
242
|
+
];
|
|
243
|
+
schema = types.length > 0 ? { type: types.length === 1 ? types[0] : types } : {};
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case "lazySchema":
|
|
247
|
+
case "memoize":
|
|
248
|
+
case "lazy":
|
|
249
|
+
case "once":
|
|
250
|
+
return schemaOf(args[0], ctx, depth + 1);
|
|
251
|
+
case "Type.Record":
|
|
252
|
+
case "t.Record":
|
|
253
|
+
case "z.record":
|
|
254
|
+
schema = { type: "object", additionalProperties: true };
|
|
255
|
+
break;
|
|
256
|
+
case "Type.Any":
|
|
257
|
+
case "Type.Unknown":
|
|
258
|
+
case "z.any":
|
|
259
|
+
case "z.unknown":
|
|
260
|
+
schema = {};
|
|
261
|
+
break;
|
|
262
|
+
default: {
|
|
263
|
+
// Factory call: `inputSchema()` where
|
|
264
|
+
// `inputSchema = lazySchema(() => z.object({...}))`.
|
|
265
|
+
if (/^[A-Za-z_$][\w$]*$/.test(callee)) {
|
|
266
|
+
const target = resolveIdent(callee, ctx, depth);
|
|
267
|
+
if (target) return schemaOf(target, ctx, depth + 1);
|
|
268
|
+
}
|
|
269
|
+
// Wrapper helpers take exactly one schema and add semantics to it
|
|
270
|
+
// (`semanticNumber(z.number().int())`). Unwrap only when exactly one
|
|
271
|
+
// argument resolves to a schema, so arrays of schemas are left alone.
|
|
272
|
+
const inner = args
|
|
273
|
+
.filter(Boolean)
|
|
274
|
+
.map((arg) => schemaOf(arg, ctx, depth + 1))
|
|
275
|
+
.filter((candidate) => candidate.resolved && isSchema(candidate.schema));
|
|
276
|
+
if (inner.length === 1) return inner[0];
|
|
277
|
+
return { schema: {}, resolved: false };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// TypeBox carries options as the first object argument, e.g.
|
|
282
|
+
// Type.String({ description: "File path", format: "uuid" }).
|
|
283
|
+
if (schema && args[0] && args[0].t === "obj" && /^(Type|t)\./.test(callee)) {
|
|
284
|
+
applyOptions(schema, args[0]);
|
|
285
|
+
}
|
|
286
|
+
for (const chained of node.calls ?? []) {
|
|
287
|
+
if (applyMethod(schema, chained) === "optional") optional = true;
|
|
288
|
+
}
|
|
289
|
+
return { schema, resolved: true, optional };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function rawShapeSchema(node, ctx, depth) {
|
|
293
|
+
const properties = {};
|
|
294
|
+
const required = [];
|
|
295
|
+
for (const [key, valueNode] of node.props) {
|
|
296
|
+
if (!valueNode || !["call", "ident", "obj"].includes(valueNode.t)) return null;
|
|
297
|
+
const { schema, resolved, optional } = schemaOf(valueNode, ctx, depth + 1);
|
|
298
|
+
if (!resolved) return null;
|
|
299
|
+
properties[key] = schema;
|
|
300
|
+
if (!optional) required.push(key);
|
|
301
|
+
}
|
|
302
|
+
if (Object.keys(properties).length === 0) return null;
|
|
303
|
+
return { type: "object", properties, required };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function objectSchema(node, ctx, depth) {
|
|
307
|
+
if (!node) return { type: "object", properties: {} };
|
|
308
|
+
const resolved = node.t === "ident" ? resolveIdent(node.name, ctx, depth) : node;
|
|
309
|
+
if (!resolved || resolved.t !== "obj") return { type: "object", properties: {} };
|
|
310
|
+
const properties = {};
|
|
311
|
+
const required = [];
|
|
312
|
+
for (const [key, valueNode] of resolved.props) {
|
|
313
|
+
const { schema, optional } = schemaOf(valueNode, ctx, depth + 1);
|
|
314
|
+
properties[key] = schema;
|
|
315
|
+
if (!optional) required.push(key);
|
|
316
|
+
}
|
|
317
|
+
return { type: "object", properties, required };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function jsonSchemaLiteral(node, ctx, depth) {
|
|
321
|
+
const schema = {};
|
|
322
|
+
const type = plainOf(node.props.get("type"));
|
|
323
|
+
if (typeof type === "string") schema.type = type;
|
|
324
|
+
const description = plainOf(node.props.get("description"));
|
|
325
|
+
if (typeof description === "string") schema.description = description;
|
|
326
|
+
const format = plainOf(node.props.get("format"));
|
|
327
|
+
if (typeof format === "string") schema.format = format;
|
|
328
|
+
const enumValues = plainOf(node.props.get("enum"));
|
|
329
|
+
if (Array.isArray(enumValues)) schema.enum = enumValues;
|
|
330
|
+
for (const key of ["default", "minimum", "maximum", "minLength", "maxLength", "pattern"]) {
|
|
331
|
+
const value = plainOf(node.props.get(key));
|
|
332
|
+
if (value !== undefined) schema[key] = value;
|
|
333
|
+
}
|
|
334
|
+
if (node.props.has("items")) {
|
|
335
|
+
const item = schemaOf(node.props.get("items"), ctx, depth + 1);
|
|
336
|
+
schema.items = item.schema;
|
|
337
|
+
}
|
|
338
|
+
if (node.props.has("properties")) {
|
|
339
|
+
const raw = node.props.get("properties");
|
|
340
|
+
const resolved = raw && raw.t === "ident" ? resolveIdent(raw.name, ctx, depth) : raw;
|
|
341
|
+
if (resolved && resolved.t === "obj") {
|
|
342
|
+
schema.type = schema.type ?? "object";
|
|
343
|
+
schema.properties = objectSchema(resolved, ctx, depth).properties;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (node.props.has("required")) {
|
|
347
|
+
const required = plainOf(node.props.get("required"));
|
|
348
|
+
if (Array.isArray(required)) schema.required = required;
|
|
349
|
+
} else if (schema.properties) {
|
|
350
|
+
schema.required = Object.keys(schema.properties);
|
|
351
|
+
}
|
|
352
|
+
if (schema.type === "object" && !schema.properties) schema.properties = {};
|
|
353
|
+
return schema;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function applyOptions(schema, optionsNode) {
|
|
357
|
+
for (const [key, valueNode] of optionsNode.props) {
|
|
358
|
+
if (key === "description") {
|
|
359
|
+
const value = plainOf(valueNode);
|
|
360
|
+
if (typeof value === "string") schema.description = value;
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (["format", "default", "minimum", "maximum", "minLength", "maxLength", "pattern"].includes(key)) {
|
|
364
|
+
const value = plainOf(valueNode);
|
|
365
|
+
if (value !== undefined) schema[key] = value;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function applyMethod(schema, chained) {
|
|
371
|
+
const [first] = chained.args ?? [];
|
|
372
|
+
switch (chained.name) {
|
|
373
|
+
case "describe": {
|
|
374
|
+
const value = plainOf(first);
|
|
375
|
+
if (typeof value === "string") schema.description = value;
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
case "optional":
|
|
379
|
+
return "optional";
|
|
380
|
+
case "default": {
|
|
381
|
+
const value = plainOf(first);
|
|
382
|
+
if (value !== undefined) schema.default = value;
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
case "min": {
|
|
386
|
+
const value = plainOf(first);
|
|
387
|
+
if (typeof value === "number") {
|
|
388
|
+
if (schema.type === "string") schema.minLength = value;
|
|
389
|
+
else schema.minimum = value;
|
|
390
|
+
}
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
case "max": {
|
|
394
|
+
const value = plainOf(first);
|
|
395
|
+
if (typeof value === "number") {
|
|
396
|
+
if (schema.type === "string") schema.maxLength = value;
|
|
397
|
+
else schema.maximum = value;
|
|
398
|
+
}
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
case "int":
|
|
402
|
+
schema.type = "integer";
|
|
403
|
+
return null;
|
|
404
|
+
case "uuid":
|
|
405
|
+
schema.format = "uuid";
|
|
406
|
+
return null;
|
|
407
|
+
default:
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function plainOf(node, depth = 0) {
|
|
413
|
+
if (!node || depth > MAX_VALUE_DEPTH) return undefined;
|
|
414
|
+
switch (node.t) {
|
|
415
|
+
case "str":
|
|
416
|
+
case "num":
|
|
417
|
+
case "bool":
|
|
418
|
+
return node.value;
|
|
419
|
+
case "null":
|
|
420
|
+
return null;
|
|
421
|
+
case "arr": {
|
|
422
|
+
const values = node.items.map((item) => plainOf(item, depth + 1));
|
|
423
|
+
return values.some((value) => value === undefined) ? undefined : values;
|
|
424
|
+
}
|
|
425
|
+
case "obj": {
|
|
426
|
+
if (node.props.has("type") || node.props.has("properties")) return undefined;
|
|
427
|
+
const value = {};
|
|
428
|
+
for (const [key, item] of node.props) {
|
|
429
|
+
const plain = plainOf(item, depth + 1);
|
|
430
|
+
if (plain === undefined) return undefined;
|
|
431
|
+
value[key] = plain;
|
|
432
|
+
}
|
|
433
|
+
return value;
|
|
434
|
+
}
|
|
435
|
+
default:
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---- bindings and identifier resolution ----------------------------------
|
|
441
|
+
|
|
442
|
+
function collectBindings(src) {
|
|
443
|
+
const bindings = new Map();
|
|
444
|
+
const pattern = /(?:^|[;\n{}])\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]{0,200})?=\s*/g;
|
|
445
|
+
for (const match of src.matchAll(pattern)) {
|
|
446
|
+
if (!bindings.has(match[1])) bindings.set(match[1], match.index + match[0].length);
|
|
447
|
+
}
|
|
448
|
+
return bindings;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resolveIdent(name, ctx, depth = 0) {
|
|
452
|
+
if (depth > MAX_RESOLVE_DEPTH) return null;
|
|
453
|
+
if (ctx.parsed.has(name)) return ctx.parsed.get(name);
|
|
454
|
+
const start = ctx.bindings.get(name);
|
|
455
|
+
if (start === undefined) return null;
|
|
456
|
+
ctx.parsed.set(name, null);
|
|
457
|
+
const parsed = parseValue(ctx.src, start, 0);
|
|
458
|
+
const node = parsed ? parsed.node : null;
|
|
459
|
+
ctx.parsed.set(name, node);
|
|
460
|
+
return node;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- value parser --------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
function parseValue(src, start, depth) {
|
|
466
|
+
if (depth > MAX_VALUE_DEPTH) return null;
|
|
467
|
+
const i = skipTrivia(src, start);
|
|
468
|
+
const ch = src[i];
|
|
469
|
+
if (ch === undefined) return null;
|
|
470
|
+
if (ch === "{") return parseObject(src, i, depth);
|
|
471
|
+
if (ch === "[") return parseArray(src, i, depth);
|
|
472
|
+
if (ch === '"' || ch === "'" || ch === "`") return parseString(src, i);
|
|
473
|
+
if (ch === "-" || (ch >= "0" && ch <= "9")) return parseNumber(src, i);
|
|
474
|
+
if (ch === "(") return tryArrow(src, i, depth);
|
|
475
|
+
const ident = readIdent(src, i);
|
|
476
|
+
if (ident) {
|
|
477
|
+
const arrow = tryArrow(src, i, depth);
|
|
478
|
+
if (arrow) return arrow;
|
|
479
|
+
if (!RESERVED.has(ident.name)) return parseChain(src, i, depth);
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// `lazySchema(() => z.object({...}))` — the schema lives in the arrow body. A
|
|
485
|
+
// block body is a function, not a value, so it becomes unknown rather than a
|
|
486
|
+
// guess.
|
|
487
|
+
function tryArrow(src, start, depth) {
|
|
488
|
+
let bodyFrom;
|
|
489
|
+
if (src[start] === "(") {
|
|
490
|
+
const close = matchParen(src, start);
|
|
491
|
+
if (close === null) return null;
|
|
492
|
+
bodyFrom = close + 1;
|
|
493
|
+
} else {
|
|
494
|
+
const ident = readIdent(src, start);
|
|
495
|
+
if (!ident) return null;
|
|
496
|
+
bodyFrom = ident.end;
|
|
497
|
+
}
|
|
498
|
+
const arrowAt = skipTrivia(src, bodyFrom);
|
|
499
|
+
if (!src.startsWith("=>", arrowAt)) return null;
|
|
500
|
+
const bodyStart = skipTrivia(src, arrowAt + 2);
|
|
501
|
+
if (src[bodyStart] === "{") {
|
|
502
|
+
const end = skipBraced(src, bodyStart + 1);
|
|
503
|
+
return end === null ? null : { node: { t: "unknown" }, end };
|
|
504
|
+
}
|
|
505
|
+
return parseValue(src, bodyStart, depth + 1);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function matchParen(src, start) {
|
|
509
|
+
let depth = 0;
|
|
510
|
+
let i = start;
|
|
511
|
+
while (i < src.length) {
|
|
512
|
+
const ch = src[i];
|
|
513
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
514
|
+
const parsed = parseString(src, i);
|
|
515
|
+
if (parsed) {
|
|
516
|
+
i = parsed.end;
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
if (ch === "(") depth += 1;
|
|
521
|
+
else if (ch === ")") {
|
|
522
|
+
depth -= 1;
|
|
523
|
+
if (depth === 0) return i;
|
|
524
|
+
}
|
|
525
|
+
i += 1;
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function parseObject(src, start, depth) {
|
|
531
|
+
const props = new Map();
|
|
532
|
+
let i = skipTrivia(src, start + 1);
|
|
533
|
+
if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
|
|
534
|
+
for (;;) {
|
|
535
|
+
i = skipTrivia(src, i);
|
|
536
|
+
// A trailing comma leaves us on the closing brace.
|
|
537
|
+
if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
|
|
538
|
+
if (src.startsWith("...", i)) return null;
|
|
539
|
+
let key = null;
|
|
540
|
+
if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
|
|
541
|
+
const parsed = parseString(src, i);
|
|
542
|
+
if (!parsed) return null;
|
|
543
|
+
key = parsed.node.value;
|
|
544
|
+
i = parsed.end;
|
|
545
|
+
} else {
|
|
546
|
+
const ident = readIdent(src, i);
|
|
547
|
+
if (ident) {
|
|
548
|
+
key = ident.name;
|
|
549
|
+
i = ident.end;
|
|
550
|
+
} else if (src[i] >= "0" && src[i] <= "9") {
|
|
551
|
+
const num = parseNumber(src, i);
|
|
552
|
+
if (!num) return null;
|
|
553
|
+
key = String(num.node.value);
|
|
554
|
+
i = num.end;
|
|
555
|
+
} else {
|
|
556
|
+
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
i = skipTrivia(src, i);
|
|
561
|
+
if (src[i] !== ":") {
|
|
562
|
+
// Property shorthand: `{ inputSchema, name, description }` refers to a
|
|
563
|
+
// local binding of the same name.
|
|
564
|
+
if (src[i] === "," || src[i] === "}") {
|
|
565
|
+
props.set(key, { t: "ident", name: key });
|
|
566
|
+
if (src[i] === ",") {
|
|
567
|
+
i += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
return { node: { t: "obj", props }, end: i + 1 };
|
|
571
|
+
}
|
|
572
|
+
// Accessors: `get inputSchema(): T { return inputSchema(); }`. Only a
|
|
573
|
+
// single-return body is taken; a conditional getter is left unknown
|
|
574
|
+
// rather than guessed from its first branch.
|
|
575
|
+
if ((key === "get" || key === "set") && /[A-Za-z_$]/.test(src[i] ?? "")) {
|
|
576
|
+
const accessor = readIdent(src, i);
|
|
577
|
+
const parenAt = skipTrivia(src, accessor.end);
|
|
578
|
+
if (src[parenAt] === "(") {
|
|
579
|
+
const params = parseArgs(src, parenAt);
|
|
580
|
+
if (!params) return null;
|
|
581
|
+
let braceAt = skipTrivia(src, params.end);
|
|
582
|
+
// `get inputSchema(): InputSchema {` — skip a simple return type.
|
|
583
|
+
if (src[braceAt] === ":") braceAt = findBrace(src, braceAt + 1);
|
|
584
|
+
if (src[braceAt] !== "{") return null;
|
|
585
|
+
const bodyEnd = skipBraced(src, braceAt + 1);
|
|
586
|
+
if (bodyEnd === null) return null;
|
|
587
|
+
props.set(accessor.name, getterValue(src, braceAt + 1, bodyEnd, depth));
|
|
588
|
+
i = skipTrivia(src, bodyEnd);
|
|
589
|
+
if (src[i] === ",") {
|
|
590
|
+
i += 1;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Shorthand methods and getters: `async call(args) { ... }`, `get x() {}`.
|
|
598
|
+
const shorthand = src[i] === "(" || src[i] === "<" || src[i] === "[" || /[A-Za-z_$]/.test(src[i] ?? "");
|
|
599
|
+
if (!shorthand) {
|
|
600
|
+
return null; }
|
|
601
|
+
const skipped = skipToValueEnd(src, i);
|
|
602
|
+
if (skipped === null) {
|
|
603
|
+
return null; }
|
|
604
|
+
props.set(key, { t: "unknown" });
|
|
605
|
+
i = skipped;
|
|
606
|
+
if (src[i] === ",") {
|
|
607
|
+
i += 1;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
const value = parseValue(src, i + 1, depth + 1);
|
|
614
|
+
const after = value ? skipTrivia(src, value.end) : -1;
|
|
615
|
+
if (value && (src[after] === "," || src[after] === "}")) {
|
|
616
|
+
props.set(key, value.node);
|
|
617
|
+
i = after;
|
|
618
|
+
} else {
|
|
619
|
+
// Unparseable or only partially parsed expression (`x => a || b`):
|
|
620
|
+
// keep the keys we understand and skip this value.
|
|
621
|
+
const skipped = skipToValueEnd(src, i + 1);
|
|
622
|
+
if (skipped === null) return null;
|
|
623
|
+
props.set(key, { t: "unknown" });
|
|
624
|
+
i = skipped;
|
|
625
|
+
}
|
|
626
|
+
if (src[i] === ",") {
|
|
627
|
+
i += 1;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function findBrace(src, start) {
|
|
636
|
+
let i = start;
|
|
637
|
+
while (i < src.length) {
|
|
638
|
+
const ch = src[i];
|
|
639
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
640
|
+
const parsed = parseString(src, i);
|
|
641
|
+
if (parsed) {
|
|
642
|
+
i = parsed.end;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (ch === "{") return i;
|
|
647
|
+
if (ch === ";" || ch === "}") return i;
|
|
648
|
+
i += 1;
|
|
649
|
+
}
|
|
650
|
+
return i;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function getterValue(src, start, end, depth) {
|
|
654
|
+
const body = src.slice(start, end);
|
|
655
|
+
const returns = body.match(/\breturn\b/g) ?? [];
|
|
656
|
+
if (returns.length !== 1) return { t: "unknown" };
|
|
657
|
+
const at = start + body.indexOf("return") + "return".length;
|
|
658
|
+
const parsed = parseValue(src, at, depth + 1);
|
|
659
|
+
return parsed ? parsed.node : { t: "unknown" };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function skipToValueEnd(src, start) {
|
|
663
|
+
let i = start;
|
|
664
|
+
let depth = 0;
|
|
665
|
+
while (i < src.length) {
|
|
666
|
+
const ch = src[i];
|
|
667
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
668
|
+
const parsed = parseString(src, i);
|
|
669
|
+
if (parsed) {
|
|
670
|
+
i = parsed.end;
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
675
|
+
const nl = src.indexOf("\n", i);
|
|
676
|
+
i = nl < 0 ? src.length : nl + 1;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
680
|
+
else if (ch === ")" || ch === "]") {
|
|
681
|
+
if (depth > 0) depth -= 1;
|
|
682
|
+
} else if (ch === "}") {
|
|
683
|
+
if (depth === 0) return i;
|
|
684
|
+
depth -= 1;
|
|
685
|
+
} else if (ch === "," && depth === 0) return i;
|
|
686
|
+
i += 1;
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function parseArray(src, start, depth) {
|
|
692
|
+
const items = [];
|
|
693
|
+
let i = skipTrivia(src, start + 1);
|
|
694
|
+
if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
|
|
695
|
+
for (;;) {
|
|
696
|
+
const value = parseValue(src, i, depth + 1);
|
|
697
|
+
if (!value) return null;
|
|
698
|
+
items.push(value.node);
|
|
699
|
+
i = skipTrivia(src, value.end);
|
|
700
|
+
if (src[i] === ",") {
|
|
701
|
+
i = skipTrivia(src, i + 1);
|
|
702
|
+
if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function parseString(src, start) {
|
|
711
|
+
const quote = src[start];
|
|
712
|
+
if (quote === "`") return parseTemplate(src, start);
|
|
713
|
+
let i = start + 1;
|
|
714
|
+
let value = "";
|
|
715
|
+
while (i < src.length) {
|
|
716
|
+
const ch = src[i];
|
|
717
|
+
if (ch === "\\") {
|
|
718
|
+
const next = src[i + 1];
|
|
719
|
+
const map = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", "0": "\0" };
|
|
720
|
+
value += map[next] ?? next ?? "";
|
|
721
|
+
i += 2;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
if (ch === quote) return { node: { t: "str", value }, end: i + 1 };
|
|
725
|
+
if (ch === "\n") return null;
|
|
726
|
+
value += ch;
|
|
727
|
+
i += 1;
|
|
728
|
+
}
|
|
729
|
+
return null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Template literals appear inside handler bodies (`Slept for ${n} seconds`).
|
|
733
|
+
// Their end must be found structurally: a naive scan confuses the closing and
|
|
734
|
+
// opening backticks of two different templates and unbalances the object parse.
|
|
735
|
+
function parseTemplate(src, start) {
|
|
736
|
+
let i = start + 1;
|
|
737
|
+
let value = "";
|
|
738
|
+
let dynamic = false;
|
|
739
|
+
while (i < src.length) {
|
|
740
|
+
const ch = src[i];
|
|
741
|
+
if (ch === "\\") {
|
|
742
|
+
value += src[i + 1] ?? "";
|
|
743
|
+
i += 2;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (ch === "`") {
|
|
747
|
+
return dynamic ? { node: { t: "unknown" }, end: i + 1 } : { node: { t: "str", value }, end: i + 1 };
|
|
748
|
+
}
|
|
749
|
+
if (ch === "$" && src[i + 1] === "{") {
|
|
750
|
+
dynamic = true;
|
|
751
|
+
const end = skipBraced(src, i + 2);
|
|
752
|
+
if (end === null) return null;
|
|
753
|
+
i = end;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
value += ch;
|
|
757
|
+
i += 1;
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function skipBraced(src, start) {
|
|
763
|
+
let depth = 1;
|
|
764
|
+
let i = start;
|
|
765
|
+
while (i < src.length) {
|
|
766
|
+
const ch = src[i];
|
|
767
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
768
|
+
const parsed = parseString(src, i);
|
|
769
|
+
if (parsed) {
|
|
770
|
+
i = parsed.end;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (ch === "{") depth += 1;
|
|
775
|
+
else if (ch === "}") {
|
|
776
|
+
depth -= 1;
|
|
777
|
+
if (depth === 0) return i + 1;
|
|
778
|
+
}
|
|
779
|
+
i += 1;
|
|
780
|
+
}
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
function parseNumber(src, start) {
|
|
785
|
+
// Numeric separators (`50_000`) are common in real tool defaults.
|
|
786
|
+
const match = /^-?\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?/i.exec(src.slice(start, start + 40));
|
|
787
|
+
if (!match) return null;
|
|
788
|
+
return { node: { t: "num", value: Number(match[0].replace(/_/g, "")) }, end: start + match[0].length };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function parseChain(src, start, depth) {
|
|
792
|
+
let i = start;
|
|
793
|
+
const segments = [];
|
|
794
|
+
const head = readIdent(src, i);
|
|
795
|
+
if (!head) return null;
|
|
796
|
+
segments.push(head.name);
|
|
797
|
+
i = head.end;
|
|
798
|
+
for (;;) {
|
|
799
|
+
const j = skipTrivia(src, i);
|
|
800
|
+
if (src[j] === ".") {
|
|
801
|
+
const next = readIdent(src, j + 1);
|
|
802
|
+
if (!next) break;
|
|
803
|
+
segments.push(next.name);
|
|
804
|
+
i = next.end;
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (src[j] === "(") {
|
|
808
|
+
const parsed = parseArgs(src, j);
|
|
809
|
+
if (!parsed) return null;
|
|
810
|
+
const calls = [];
|
|
811
|
+
let end = parsed.end;
|
|
812
|
+
for (;;) {
|
|
813
|
+
const k = skipTrivia(src, end);
|
|
814
|
+
if (src[k] !== ".") break;
|
|
815
|
+
const method = readIdent(src, k + 1);
|
|
816
|
+
if (!method) break;
|
|
817
|
+
const l = skipTrivia(src, method.end);
|
|
818
|
+
if (src[l] !== "(") break;
|
|
819
|
+
const methodArgs = parseArgs(src, l);
|
|
820
|
+
if (!methodArgs) break;
|
|
821
|
+
calls.push({ name: method.name, args: methodArgs.items });
|
|
822
|
+
end = methodArgs.end;
|
|
823
|
+
}
|
|
824
|
+
return { node: { t: "call", callee: segments.join("."), args: parsed.items, calls }, end };
|
|
825
|
+
}
|
|
826
|
+
break;
|
|
827
|
+
}
|
|
828
|
+
return { node: { t: "ident", name: segments.join(".") }, end: i };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function parseArgs(src, start) {
|
|
832
|
+
const items = [];
|
|
833
|
+
let i = skipTrivia(src, start + 1);
|
|
834
|
+
if (src[i] === ")") return { items, end: i + 1 };
|
|
835
|
+
for (;;) {
|
|
836
|
+
let value = parseValue(src, i, 0);
|
|
837
|
+
let after = value ? skipTrivia(src, value.end) : -1;
|
|
838
|
+
if (value) {
|
|
839
|
+
// TypeScript wrappers: `{...} satisfies ToolDef<...>` and `x as T`.
|
|
840
|
+
if (isTypeWrapperAt(src, after)) {
|
|
841
|
+
const skipped = skipTypeExpression(src, after);
|
|
842
|
+
if (skipped === null) return null;
|
|
843
|
+
after = skipped;
|
|
844
|
+
}
|
|
845
|
+
if (src[after] === "," || src[after] === ")") {
|
|
846
|
+
items.push(value.node);
|
|
847
|
+
i = after;
|
|
848
|
+
} else {
|
|
849
|
+
// Binary expressions (`a || b`, `a ?? b`) parse only their first
|
|
850
|
+
// operand; treat the whole argument as unknown instead.
|
|
851
|
+
value = null;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (!value) {
|
|
855
|
+
const skipped = skipArgument(src, i);
|
|
856
|
+
if (skipped === null) return null;
|
|
857
|
+
items.push(null);
|
|
858
|
+
i = skipped;
|
|
859
|
+
}
|
|
860
|
+
if (src[i] === ",") {
|
|
861
|
+
i = skipTrivia(src, i + 1);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (src[i] === ")") return { items, end: i + 1 };
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function isTypeWrapperAt(src, i) {
|
|
870
|
+
return (
|
|
871
|
+
(src.startsWith("satisfies", i) && !/[\w$]/.test(src[i + 9] ?? "")) ||
|
|
872
|
+
(src.startsWith("as", i) && /[\s<]/.test(src[i + 2] ?? ""))
|
|
873
|
+
);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Skips an annotation after satisfies/as. Generic argument commas are not
|
|
877
|
+
// argument separators, so `<...>` is tracked as depth.
|
|
878
|
+
function skipTypeExpression(src, start) {
|
|
879
|
+
let i = start;
|
|
880
|
+
let paren = 0;
|
|
881
|
+
let angle = 0;
|
|
882
|
+
while (i < src.length) {
|
|
883
|
+
const ch = src[i];
|
|
884
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
885
|
+
const parsed = parseString(src, i);
|
|
886
|
+
if (parsed) {
|
|
887
|
+
i = parsed.end;
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (ch === "(" || ch === "[" || ch === "{") paren += 1;
|
|
892
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
893
|
+
if (paren === 0) return i;
|
|
894
|
+
paren -= 1;
|
|
895
|
+
} else if (ch === "<") angle += 1;
|
|
896
|
+
else if (ch === ">" && angle > 0) angle -= 1;
|
|
897
|
+
else if (ch === "," && paren === 0 && angle === 0) return i;
|
|
898
|
+
i += 1;
|
|
899
|
+
}
|
|
900
|
+
return null;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// A handler argument (`async (args) => {...}`) is not a value we can parse; step
|
|
904
|
+
// over it so one unparseable parameter does not discard the whole definition.
|
|
905
|
+
function skipArgument(src, start) {
|
|
906
|
+
let i = start;
|
|
907
|
+
let depth = 0;
|
|
908
|
+
while (i < src.length) {
|
|
909
|
+
const ch = src[i];
|
|
910
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
911
|
+
const parsed = parseString(src, i);
|
|
912
|
+
if (parsed) {
|
|
913
|
+
i = parsed.end;
|
|
914
|
+
continue;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
918
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
919
|
+
if (depth === 0) return ch === ")" ? i : null;
|
|
920
|
+
depth -= 1;
|
|
921
|
+
} else if (ch === "," && depth === 0) return i;
|
|
922
|
+
i += 1;
|
|
923
|
+
}
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function readIdent(src, start) {
|
|
928
|
+
const match = /^[A-Za-z_$][\w$]*/.exec(src.slice(start, start + 120));
|
|
929
|
+
if (!match) return null;
|
|
930
|
+
return { name: match[0], end: start + match[0].length };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function skipTrivia(src, start) {
|
|
934
|
+
let i = start;
|
|
935
|
+
while (i < src.length) {
|
|
936
|
+
const ch = src[i];
|
|
937
|
+
if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
|
|
938
|
+
i += 1;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (ch === "/" && src[i + 1] === "/") {
|
|
942
|
+
const nl = src.indexOf("\n", i);
|
|
943
|
+
i = nl < 0 ? src.length : nl + 1;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (ch === "/" && src[i + 1] === "*") {
|
|
947
|
+
const close = src.indexOf("*/", i + 2);
|
|
948
|
+
i = close < 0 ? src.length : close + 2;
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
return i;
|
|
952
|
+
}
|
|
953
|
+
return i;
|
|
954
|
+
}
|
package/lib/mcp-server.js
CHANGED
|
@@ -57,6 +57,7 @@ export async function startMcpServer() {
|
|
|
57
57
|
source: mcpServer.source,
|
|
58
58
|
origin: "mcp-probe",
|
|
59
59
|
confidence: "high",
|
|
60
|
+
replaceExisting: true,
|
|
60
61
|
note: `server ${mcpServer.name}`
|
|
61
62
|
});
|
|
62
63
|
report.probes.push({ server: mcpServer.name, ok: true, toolCount: added.length, tools: added });
|
package/lib/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { extractJsTools } from "./js-tools.js";
|
|
3
4
|
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
"node_modules",
|
|
@@ -19,7 +20,10 @@ const SKIP_DIRS = new Set([
|
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
const MAX_DEPTH = 6;
|
|
22
|
-
|
|
23
|
+
// 5k was too small: a real agent monorepo (openclaw) has 16k files after the
|
|
24
|
+
// skip list, so the walk stopped a third of the way in and reported 26 of its
|
|
25
|
+
// 53 skills. Silent partial results are worse than a slow scan.
|
|
26
|
+
const MAX_FILES = 25000;
|
|
23
27
|
const MAX_FILE_BYTES = 200_000;
|
|
24
28
|
|
|
25
29
|
const FUNCTION_SPEC_NAMES = new Set([
|
|
@@ -81,7 +85,8 @@ const JS_FRAMEWORKS = {
|
|
|
81
85
|
export async function scanProject(root = ".") {
|
|
82
86
|
const absRoot = resolve(root);
|
|
83
87
|
const files = [];
|
|
84
|
-
|
|
88
|
+
const state = { truncated: false };
|
|
89
|
+
await walk(absRoot, 0, files, state);
|
|
85
90
|
|
|
86
91
|
const report = {
|
|
87
92
|
root: absRoot,
|
|
@@ -97,7 +102,16 @@ export async function scanProject(root = ".") {
|
|
|
97
102
|
warnings: []
|
|
98
103
|
};
|
|
99
104
|
|
|
105
|
+
if (state.truncated) {
|
|
106
|
+
report.warnings.push(`扫描在 ${MAX_FILES} 个文件处截断,结果可能不完整;可把 --root 指向子目录`);
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
const seenTools = new Set();
|
|
110
|
+
// Tool names are frequently constants defined in a sibling module
|
|
111
|
+
// (`name: CONFIG_TOOL_NAME`). Files are visited in path order, so the
|
|
112
|
+
// constant may not be known yet; such files are retried after the walk.
|
|
113
|
+
const constIndex = new Map();
|
|
114
|
+
const retryFiles = new Set();
|
|
101
115
|
const dependencies = new Set();
|
|
102
116
|
const frameworks = new Set();
|
|
103
117
|
|
|
@@ -224,6 +238,23 @@ export async function scanProject(root = ".") {
|
|
|
224
238
|
});
|
|
225
239
|
}
|
|
226
240
|
}
|
|
241
|
+
if (!name.endsWith(".py") && !TEST_PATH.test(rel)) {
|
|
242
|
+
for (const match of text.matchAll(/(?:export\s+)?const\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(['"])([^'"\n]{1,200})\2/g)) {
|
|
243
|
+
if (!constIndex.has(match[1])) constIndex.set(match[1], match[3]);
|
|
244
|
+
}
|
|
245
|
+
const jsTools = extractJsTools(text, {
|
|
246
|
+
externals: constIndex,
|
|
247
|
+
onUnresolvedName: () => retryFiles.add(file)
|
|
248
|
+
});
|
|
249
|
+
if (jsTools.length > 0) {
|
|
250
|
+
addTools(report, jsTools, {
|
|
251
|
+
source: rel,
|
|
252
|
+
origin: "code-literal",
|
|
253
|
+
confidence: "medium",
|
|
254
|
+
note: "从 JS/TS 声明或 TypeBox/zod schema 静态提取(不执行代码),需人工确认"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
227
258
|
if (TEST_PATH.test(rel)) continue;
|
|
228
259
|
// Only matches that carry a tool name are actionable; a bare `input_schema:`
|
|
229
260
|
// in API plumbing is not a tool and only inflates the report.
|
|
@@ -249,19 +280,46 @@ export async function scanProject(root = ".") {
|
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
|
|
283
|
+
if (retryFiles.size > 0 && constIndex.size > 0) {
|
|
284
|
+
for (const file of retryFiles) {
|
|
285
|
+
const text = await readText(file, report);
|
|
286
|
+
if (!text) continue;
|
|
287
|
+
const tools = extractJsTools(text, { externals: constIndex });
|
|
288
|
+
if (tools.length === 0) continue;
|
|
289
|
+
addTools(report, tools, {
|
|
290
|
+
source: relative(absRoot, file),
|
|
291
|
+
origin: "code-literal",
|
|
292
|
+
confidence: "medium",
|
|
293
|
+
note: "名称常量在其他模块定义,扫描完整仓库后再解析"
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
252
298
|
report.dependencies = [...dependencies].sort();
|
|
253
299
|
report.frameworks = [...frameworks].sort();
|
|
254
300
|
return report;
|
|
255
301
|
}
|
|
256
302
|
|
|
257
|
-
export function addTools(
|
|
258
|
-
|
|
303
|
+
export function addTools(
|
|
304
|
+
report,
|
|
305
|
+
tools,
|
|
306
|
+
{ source, origin, confidence = "high", note, meta, replaceExisting = false }
|
|
307
|
+
) {
|
|
308
|
+
const index = new Map(report.tools.map((tool, position) => [tool.name, position]));
|
|
259
309
|
const added = [];
|
|
260
310
|
for (const tool of tools) {
|
|
261
|
-
if (!tool?.name
|
|
262
|
-
existing.
|
|
263
|
-
|
|
264
|
-
|
|
311
|
+
if (!tool?.name) continue;
|
|
312
|
+
const existing = index.get(tool.name);
|
|
313
|
+
if (existing === undefined) {
|
|
314
|
+
index.set(tool.name, report.tools.length);
|
|
315
|
+
report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
|
|
316
|
+
added.push(tool.name);
|
|
317
|
+
} else if (replaceExisting) {
|
|
318
|
+
// A probed tool is the runtime truth; a static guess with the same name
|
|
319
|
+
// should be replaced, not silently kept.
|
|
320
|
+
report.tools[existing] = { ...tool, source, origin, ...(meta ?? {}) };
|
|
321
|
+
added.push(tool.name);
|
|
322
|
+
}
|
|
265
323
|
}
|
|
266
324
|
report.sources.push({
|
|
267
325
|
type: origin,
|
|
@@ -387,8 +445,11 @@ export function formatScanSummary(report) {
|
|
|
387
445
|
return lines.join("\n");
|
|
388
446
|
}
|
|
389
447
|
|
|
390
|
-
async function walk(dir, depth, files) {
|
|
391
|
-
if (depth > MAX_DEPTH || files.length >= MAX_FILES)
|
|
448
|
+
async function walk(dir, depth, files, state) {
|
|
449
|
+
if (depth > MAX_DEPTH || files.length >= MAX_FILES) {
|
|
450
|
+
if (files.length >= MAX_FILES) state.truncated = true;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
392
453
|
let entries;
|
|
393
454
|
try {
|
|
394
455
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -397,11 +458,14 @@ async function walk(dir, depth, files) {
|
|
|
397
458
|
}
|
|
398
459
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
399
460
|
for (const entry of entries) {
|
|
400
|
-
if (files.length >= MAX_FILES)
|
|
461
|
+
if (files.length >= MAX_FILES) {
|
|
462
|
+
state.truncated = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
401
465
|
const full = join(dir, entry.name);
|
|
402
466
|
if (entry.isDirectory()) {
|
|
403
467
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
404
|
-
await walk(full, depth + 1, files);
|
|
468
|
+
await walk(full, depth + 1, files, state);
|
|
405
469
|
} else if (entry.isFile()) {
|
|
406
470
|
files.push(full);
|
|
407
471
|
}
|
package/lib/suite.js
CHANGED
|
@@ -3,10 +3,10 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
const EMPTY_SCHEMA = { type: "object", properties: {} };
|
|
5
5
|
|
|
6
|
-
export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
|
|
6
|
+
export async function loadSuite(suitePath, { allowEmptyCases = false, allowEmptyTools = false } = {}) {
|
|
7
7
|
const abs = resolve(suitePath);
|
|
8
8
|
const raw = JSON.parse(await readFile(abs, "utf8"));
|
|
9
|
-
if (!Array.isArray(raw.tools) || raw.tools.length === 0) {
|
|
9
|
+
if (!Array.isArray(raw.tools) || (raw.tools.length === 0 && !allowEmptyTools)) {
|
|
10
10
|
throw new Error(`suite ${abs}: "tools" must be a non-empty array`);
|
|
11
11
|
}
|
|
12
12
|
if (!Array.isArray(raw.cases) || (raw.cases.length === 0 && !allowEmptyCases)) {
|
package/mf.js
CHANGED
|
@@ -344,6 +344,7 @@ export async function runScan(options, io = console) {
|
|
|
344
344
|
source: server.source,
|
|
345
345
|
origin: "mcp-probe",
|
|
346
346
|
confidence: "high",
|
|
347
|
+
replaceExisting: true,
|
|
347
348
|
note: `server ${server.name}`,
|
|
348
349
|
meta: { provider: server.name }
|
|
349
350
|
});
|
|
@@ -408,8 +409,11 @@ export async function runCaseAdd(options, io = console) {
|
|
|
408
409
|
|
|
409
410
|
export async function runCoverage(options, io = console) {
|
|
410
411
|
// A scan draft has no cases yet; "what is still missing" is exactly the view
|
|
411
|
-
// a user needs right after scanning.
|
|
412
|
-
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
412
|
+
// a user needs right after scanning. A skills-only suite is also valid.
|
|
413
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
414
|
+
allowEmptyCases: true,
|
|
415
|
+
allowEmptyTools: true
|
|
416
|
+
});
|
|
413
417
|
const report = computeCoverage(suite);
|
|
414
418
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
415
419
|
return 0;
|
|
@@ -422,7 +426,10 @@ export async function runSandboxCheck(options, io = console) {
|
|
|
422
426
|
}
|
|
423
427
|
|
|
424
428
|
export async function runLint(options, io = console) {
|
|
425
|
-
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
429
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
430
|
+
allowEmptyCases: true,
|
|
431
|
+
allowEmptyTools: true
|
|
432
|
+
});
|
|
426
433
|
const report = lintSuite(suite);
|
|
427
434
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
428
435
|
return report.counts.error > 0 ? 1 : 0;
|