modelfunction-cli 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`);`--probe` 连 MCP server;`--from` 导入运行时工具数组;草稿按来源分组(`profiles`)并列出未覆盖的 skill |
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` 时按组统计 |
@@ -0,0 +1,965 @@
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
+ // Claude-style tools build the description in `async description()` or a
119
+ // prompt template; lint must not call that "missing".
120
+ const descNode = node.props.get("description");
121
+ const runtimeDescription = !description && (descNode?.t === "unknown" || node.props.has("prompt"));
122
+ return { name, description, parameters: schema, ...(runtimeDescription ? { descriptionSource: "runtime" } : {}) };
123
+ }
124
+
125
+ function isSchema(value) {
126
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0;
127
+ }
128
+
129
+ function stringOf(node, ctx) {
130
+ if (!node) return null;
131
+ if (node.t === "str") return node.value;
132
+ if (node.t === "ident") {
133
+ // `name: CONFIG_TOOL_NAME` — a local const, or a repo-wide uppercase
134
+ // constant indexed by the scanner across files.
135
+ const local = resolveIdent(node.name, ctx);
136
+ if (local && local.t === "str") return local.value;
137
+ const external = ctx?.externals?.get(node.name);
138
+ if (typeof external === "string") return external;
139
+ ctx?.onUnresolvedName?.(node.name);
140
+ return null;
141
+ }
142
+ if (node.t === "obj") {
143
+ const value = node.props.get("value");
144
+ if (value && value.t === "str") return value.value;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ // ---- schema construction -------------------------------------------------
150
+
151
+ function schemaOf(node, ctx, depth = 0) {
152
+ if (!node || depth > MAX_VALUE_DEPTH) return { schema: {}, resolved: false };
153
+ if (node.t === "ident") {
154
+ const resolvedNode = resolveIdent(node.name, ctx, depth);
155
+ if (!resolvedNode) return { schema: {}, resolved: false };
156
+ return schemaOf(resolvedNode, ctx, depth + 1);
157
+ }
158
+ if (node.t === "obj") {
159
+ if (node.props.has("type") || node.props.has("properties") || node.props.has("items")) {
160
+ return { schema: jsonSchemaLiteral(node, ctx, depth), resolved: true };
161
+ }
162
+ // MCP SDK shape argument: server.tool("x", "desc", { query: z.string() }).
163
+ const rawShape = rawShapeSchema(node, ctx, depth);
164
+ return rawShape ? { schema: rawShape, resolved: true } : { schema: {}, resolved: false };
165
+ }
166
+ if (node.t !== "call") return { schema: {}, resolved: false };
167
+
168
+ const args = node.args ?? [];
169
+ const callee = node.callee;
170
+ let schema = null;
171
+ let optional = false;
172
+
173
+ switch (callee) {
174
+ case "Type.Object":
175
+ case "t.Object":
176
+ case "z.object":
177
+ case "z.strictObject":
178
+ case "z.looseObject":
179
+ schema = objectSchema(args[0], ctx, depth);
180
+ break;
181
+ case "Type.String":
182
+ case "t.String":
183
+ case "z.string":
184
+ schema = { type: "string" };
185
+ break;
186
+ case "Type.Number":
187
+ case "t.Number":
188
+ case "z.number":
189
+ schema = { type: "number" };
190
+ break;
191
+ case "Type.Integer":
192
+ case "t.Integer":
193
+ case "z.int":
194
+ schema = { type: "integer" };
195
+ break;
196
+ case "z.coerce.number":
197
+ schema = { type: "number" };
198
+ break;
199
+ case "z.coerce.string":
200
+ schema = { type: "string" };
201
+ break;
202
+ case "z.coerce.boolean":
203
+ schema = { type: "boolean" };
204
+ break;
205
+ case "Type.Boolean":
206
+ case "t.Boolean":
207
+ case "z.boolean":
208
+ schema = { type: "boolean" };
209
+ break;
210
+ case "Type.Array":
211
+ case "t.Array":
212
+ case "z.array": {
213
+ const item = schemaOf(args[0], ctx, depth + 1);
214
+ schema = { type: "array", items: item.schema };
215
+ break;
216
+ }
217
+ case "Type.Optional":
218
+ case "z.optional": {
219
+ const inner = schemaOf(args[0], ctx, depth + 1);
220
+ return { ...inner, optional: true };
221
+ }
222
+ case "Type.Literal":
223
+ case "t.Literal":
224
+ case "z.literal": {
225
+ const value = plainOf(args[0]);
226
+ schema = value === undefined ? {} : { enum: [value] };
227
+ break;
228
+ }
229
+ case "z.enum": {
230
+ const values = plainOf(args[0]);
231
+ schema = Array.isArray(values) ? { type: "string", enum: values } : {};
232
+ break;
233
+ }
234
+ case "Type.Union":
235
+ case "t.Union":
236
+ case "z.union": {
237
+ const nodes = args.length === 1 && args[0] && args[0].t === "arr" ? args[0].items : args;
238
+ const literals = nodes.map((item) => plainOf(item));
239
+ if (literals.length > 0 && literals.every((item) => item !== undefined)) {
240
+ schema = { enum: literals };
241
+ break;
242
+ }
243
+ // z.union([z.string(), z.boolean()]) — keep the types we can name.
244
+ const types = [
245
+ ...new Set(nodes.map((item) => schemaOf(item, ctx, depth + 1).schema?.type).filter((t) => typeof t === "string"))
246
+ ];
247
+ schema = types.length > 0 ? { type: types.length === 1 ? types[0] : types } : {};
248
+ break;
249
+ }
250
+ case "lazySchema":
251
+ case "memoize":
252
+ case "lazy":
253
+ case "once":
254
+ return schemaOf(args[0], ctx, depth + 1);
255
+ case "Type.Record":
256
+ case "t.Record":
257
+ case "z.record":
258
+ schema = { type: "object", additionalProperties: true };
259
+ break;
260
+ case "Type.Any":
261
+ case "Type.Unknown":
262
+ case "z.any":
263
+ case "z.unknown":
264
+ schema = {};
265
+ break;
266
+ default: {
267
+ // Factory call: `inputSchema()` where
268
+ // `inputSchema = lazySchema(() => z.object({...}))`.
269
+ if (/^[A-Za-z_$][\w$]*$/.test(callee)) {
270
+ const target = resolveIdent(callee, ctx, depth);
271
+ if (target) return schemaOf(target, ctx, depth + 1);
272
+ }
273
+ // Wrapper helpers take exactly one schema and add semantics to it
274
+ // (`semanticNumber(z.number().int())`). Unwrap only when exactly one
275
+ // argument resolves to a schema, so arrays of schemas are left alone.
276
+ const inner = args
277
+ .filter(Boolean)
278
+ .map((arg) => schemaOf(arg, ctx, depth + 1))
279
+ .filter((candidate) => candidate.resolved && isSchema(candidate.schema));
280
+ if (inner.length === 1) return inner[0];
281
+ return { schema: {}, resolved: false };
282
+ }
283
+ }
284
+
285
+ // TypeBox carries options as the first object argument, e.g.
286
+ // Type.String({ description: "File path", format: "uuid" }).
287
+ if (schema && args[0] && args[0].t === "obj" && /^(Type|t)\./.test(callee)) {
288
+ applyOptions(schema, args[0]);
289
+ }
290
+ for (const chained of node.calls ?? []) {
291
+ if (applyMethod(schema, chained) === "optional") optional = true;
292
+ }
293
+ return { schema, resolved: true, optional };
294
+ }
295
+
296
+ function rawShapeSchema(node, ctx, depth) {
297
+ const properties = {};
298
+ const required = [];
299
+ for (const [key, valueNode] of node.props) {
300
+ if (!valueNode || !["call", "ident", "obj"].includes(valueNode.t)) return null;
301
+ const { schema, resolved, optional } = schemaOf(valueNode, ctx, depth + 1);
302
+ if (!resolved) return null;
303
+ properties[key] = schema;
304
+ if (!optional) required.push(key);
305
+ }
306
+ if (Object.keys(properties).length === 0) return null;
307
+ return { type: "object", properties, required };
308
+ }
309
+
310
+ function objectSchema(node, ctx, depth) {
311
+ if (!node) return { type: "object", properties: {} };
312
+ const resolved = node.t === "ident" ? resolveIdent(node.name, ctx, depth) : node;
313
+ if (!resolved || resolved.t !== "obj") return { type: "object", properties: {} };
314
+ const properties = {};
315
+ const required = [];
316
+ for (const [key, valueNode] of resolved.props) {
317
+ const { schema, optional } = schemaOf(valueNode, ctx, depth + 1);
318
+ properties[key] = schema;
319
+ if (!optional) required.push(key);
320
+ }
321
+ return { type: "object", properties, required };
322
+ }
323
+
324
+ function jsonSchemaLiteral(node, ctx, depth) {
325
+ const schema = {};
326
+ const type = plainOf(node.props.get("type"));
327
+ if (typeof type === "string") schema.type = type;
328
+ const description = plainOf(node.props.get("description"));
329
+ if (typeof description === "string") schema.description = description;
330
+ const format = plainOf(node.props.get("format"));
331
+ if (typeof format === "string") schema.format = format;
332
+ const enumValues = plainOf(node.props.get("enum"));
333
+ if (Array.isArray(enumValues)) schema.enum = enumValues;
334
+ for (const key of ["default", "minimum", "maximum", "minLength", "maxLength", "pattern"]) {
335
+ const value = plainOf(node.props.get(key));
336
+ if (value !== undefined) schema[key] = value;
337
+ }
338
+ if (node.props.has("items")) {
339
+ const item = schemaOf(node.props.get("items"), ctx, depth + 1);
340
+ schema.items = item.schema;
341
+ }
342
+ if (node.props.has("properties")) {
343
+ const raw = node.props.get("properties");
344
+ const resolved = raw && raw.t === "ident" ? resolveIdent(raw.name, ctx, depth) : raw;
345
+ if (resolved && resolved.t === "obj") {
346
+ schema.type = schema.type ?? "object";
347
+ schema.properties = objectSchema(resolved, ctx, depth).properties;
348
+ }
349
+ }
350
+ if (node.props.has("required")) {
351
+ const required = plainOf(node.props.get("required"));
352
+ if (Array.isArray(required)) schema.required = required;
353
+ } else if (schema.properties) {
354
+ schema.required = Object.keys(schema.properties);
355
+ }
356
+ if (schema.type === "object" && !schema.properties) schema.properties = {};
357
+ return schema;
358
+ }
359
+
360
+ function applyOptions(schema, optionsNode) {
361
+ for (const [key, valueNode] of optionsNode.props) {
362
+ if (key === "description") {
363
+ const value = plainOf(valueNode);
364
+ if (typeof value === "string") schema.description = value;
365
+ continue;
366
+ }
367
+ if (["format", "default", "minimum", "maximum", "minLength", "maxLength", "pattern"].includes(key)) {
368
+ const value = plainOf(valueNode);
369
+ if (value !== undefined) schema[key] = value;
370
+ }
371
+ }
372
+ }
373
+
374
+ function applyMethod(schema, chained) {
375
+ const [first] = chained.args ?? [];
376
+ switch (chained.name) {
377
+ case "describe": {
378
+ const value = plainOf(first);
379
+ if (typeof value === "string") schema.description = value;
380
+ return null;
381
+ }
382
+ case "optional":
383
+ return "optional";
384
+ case "default": {
385
+ const value = plainOf(first);
386
+ if (value !== undefined) schema.default = value;
387
+ return null;
388
+ }
389
+ case "min": {
390
+ const value = plainOf(first);
391
+ if (typeof value === "number") {
392
+ if (schema.type === "string") schema.minLength = value;
393
+ else schema.minimum = value;
394
+ }
395
+ return null;
396
+ }
397
+ case "max": {
398
+ const value = plainOf(first);
399
+ if (typeof value === "number") {
400
+ if (schema.type === "string") schema.maxLength = value;
401
+ else schema.maximum = value;
402
+ }
403
+ return null;
404
+ }
405
+ case "int":
406
+ schema.type = "integer";
407
+ return null;
408
+ case "uuid":
409
+ schema.format = "uuid";
410
+ return null;
411
+ default:
412
+ return null;
413
+ }
414
+ }
415
+
416
+ function plainOf(node, depth = 0) {
417
+ if (!node || depth > MAX_VALUE_DEPTH) return undefined;
418
+ switch (node.t) {
419
+ case "str":
420
+ case "num":
421
+ case "bool":
422
+ return node.value;
423
+ case "null":
424
+ return null;
425
+ case "arr": {
426
+ const values = node.items.map((item) => plainOf(item, depth + 1));
427
+ return values.some((value) => value === undefined) ? undefined : values;
428
+ }
429
+ case "obj": {
430
+ if (node.props.has("type") || node.props.has("properties")) return undefined;
431
+ const value = {};
432
+ for (const [key, item] of node.props) {
433
+ const plain = plainOf(item, depth + 1);
434
+ if (plain === undefined) return undefined;
435
+ value[key] = plain;
436
+ }
437
+ return value;
438
+ }
439
+ default:
440
+ return undefined;
441
+ }
442
+ }
443
+
444
+ // ---- bindings and identifier resolution ----------------------------------
445
+
446
+ function collectBindings(src) {
447
+ const bindings = new Map();
448
+ const pattern = /(?:^|[;\n{}])\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*(?::[^=\n]{0,200})?=\s*/g;
449
+ for (const match of src.matchAll(pattern)) {
450
+ if (!bindings.has(match[1])) bindings.set(match[1], match.index + match[0].length);
451
+ }
452
+ return bindings;
453
+ }
454
+
455
+ function resolveIdent(name, ctx, depth = 0) {
456
+ if (depth > MAX_RESOLVE_DEPTH) return null;
457
+ if (ctx.parsed.has(name)) return ctx.parsed.get(name);
458
+ const start = ctx.bindings.get(name);
459
+ if (start === undefined) return null;
460
+ ctx.parsed.set(name, null);
461
+ const parsed = parseValue(ctx.src, start, 0);
462
+ const node = parsed ? parsed.node : null;
463
+ ctx.parsed.set(name, node);
464
+ return node;
465
+ }
466
+
467
+ // ---- value parser --------------------------------------------------------
468
+
469
+ function parseValue(src, start, depth) {
470
+ if (depth > MAX_VALUE_DEPTH) return null;
471
+ const i = skipTrivia(src, start);
472
+ const ch = src[i];
473
+ if (ch === undefined) return null;
474
+ if (ch === "{") return parseObject(src, i, depth);
475
+ if (ch === "[") return parseArray(src, i, depth);
476
+ if (ch === '"' || ch === "'" || ch === "`") return parseString(src, i);
477
+ if (ch === "-" || (ch >= "0" && ch <= "9")) return parseNumber(src, i);
478
+ if (ch === "(") return tryArrow(src, i, depth);
479
+ const ident = readIdent(src, i);
480
+ if (ident) {
481
+ const arrow = tryArrow(src, i, depth);
482
+ if (arrow) return arrow;
483
+ if (!RESERVED.has(ident.name)) return parseChain(src, i, depth);
484
+ }
485
+ return null;
486
+ }
487
+
488
+ // `lazySchema(() => z.object({...}))` — the schema lives in the arrow body. A
489
+ // block body is a function, not a value, so it becomes unknown rather than a
490
+ // guess.
491
+ function tryArrow(src, start, depth) {
492
+ let bodyFrom;
493
+ if (src[start] === "(") {
494
+ const close = matchParen(src, start);
495
+ if (close === null) return null;
496
+ bodyFrom = close + 1;
497
+ } else {
498
+ const ident = readIdent(src, start);
499
+ if (!ident) return null;
500
+ bodyFrom = ident.end;
501
+ }
502
+ const arrowAt = skipTrivia(src, bodyFrom);
503
+ if (!src.startsWith("=>", arrowAt)) return null;
504
+ const bodyStart = skipTrivia(src, arrowAt + 2);
505
+ if (src[bodyStart] === "{") {
506
+ const end = skipBraced(src, bodyStart + 1);
507
+ return end === null ? null : { node: { t: "unknown" }, end };
508
+ }
509
+ return parseValue(src, bodyStart, depth + 1);
510
+ }
511
+
512
+ function matchParen(src, start) {
513
+ let depth = 0;
514
+ let i = start;
515
+ while (i < src.length) {
516
+ const ch = src[i];
517
+ if (ch === '"' || ch === "'" || ch === "`") {
518
+ const parsed = parseString(src, i);
519
+ if (parsed) {
520
+ i = parsed.end;
521
+ continue;
522
+ }
523
+ }
524
+ if (ch === "(") depth += 1;
525
+ else if (ch === ")") {
526
+ depth -= 1;
527
+ if (depth === 0) return i;
528
+ }
529
+ i += 1;
530
+ }
531
+ return null;
532
+ }
533
+
534
+ function parseObject(src, start, depth) {
535
+ const props = new Map();
536
+ let i = skipTrivia(src, start + 1);
537
+ if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
538
+ for (;;) {
539
+ i = skipTrivia(src, i);
540
+ // A trailing comma leaves us on the closing brace.
541
+ if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
542
+ if (src.startsWith("...", i)) return null;
543
+ let key = null;
544
+ if (src[i] === '"' || src[i] === "'" || src[i] === "`") {
545
+ const parsed = parseString(src, i);
546
+ if (!parsed) return null;
547
+ key = parsed.node.value;
548
+ i = parsed.end;
549
+ } else {
550
+ const ident = readIdent(src, i);
551
+ if (ident) {
552
+ key = ident.name;
553
+ i = ident.end;
554
+ } else if (src[i] >= "0" && src[i] <= "9") {
555
+ const num = parseNumber(src, i);
556
+ if (!num) return null;
557
+ key = String(num.node.value);
558
+ i = num.end;
559
+ } else {
560
+
561
+ return null;
562
+ }
563
+ }
564
+ i = skipTrivia(src, i);
565
+ if (src[i] !== ":") {
566
+ // Property shorthand: `{ inputSchema, name, description }` refers to a
567
+ // local binding of the same name.
568
+ if (src[i] === "," || src[i] === "}") {
569
+ props.set(key, { t: "ident", name: key });
570
+ if (src[i] === ",") {
571
+ i += 1;
572
+ continue;
573
+ }
574
+ return { node: { t: "obj", props }, end: i + 1 };
575
+ }
576
+ // Accessors: `get inputSchema(): T { return inputSchema(); }`. Only a
577
+ // single-return body is taken; a conditional getter is left unknown
578
+ // rather than guessed from its first branch.
579
+ if ((key === "get" || key === "set") && /[A-Za-z_$]/.test(src[i] ?? "")) {
580
+ const accessor = readIdent(src, i);
581
+ const parenAt = skipTrivia(src, accessor.end);
582
+ if (src[parenAt] === "(") {
583
+ const params = parseArgs(src, parenAt);
584
+ if (!params) return null;
585
+ let braceAt = skipTrivia(src, params.end);
586
+ // `get inputSchema(): InputSchema {` — skip a simple return type.
587
+ if (src[braceAt] === ":") braceAt = findBrace(src, braceAt + 1);
588
+ if (src[braceAt] !== "{") return null;
589
+ const bodyEnd = skipBraced(src, braceAt + 1);
590
+ if (bodyEnd === null) return null;
591
+ props.set(accessor.name, getterValue(src, braceAt + 1, bodyEnd, depth));
592
+ i = skipTrivia(src, bodyEnd);
593
+ if (src[i] === ",") {
594
+ i += 1;
595
+ continue;
596
+ }
597
+ if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
598
+ return null;
599
+ }
600
+ }
601
+ // Shorthand methods and getters: `async call(args) { ... }`, `get x() {}`.
602
+ const shorthand = src[i] === "(" || src[i] === "<" || src[i] === "[" || /[A-Za-z_$]/.test(src[i] ?? "");
603
+ if (!shorthand) {
604
+ return null; }
605
+ const skipped = skipToValueEnd(src, i);
606
+ if (skipped === null) {
607
+ return null; }
608
+ // `async description() {...}` — record the method's real name so lint can
609
+ // tell "description generated at runtime" apart from "no description".
610
+ let methodName = key;
611
+ if ((key === "async" || key === "get" || key === "set") && /[A-Za-z_$]/.test(src[i] ?? "")) {
612
+ const inner = readIdent(src, i);
613
+ if (inner && src[skipTrivia(src, inner.end)] === "(") methodName = inner.name;
614
+ }
615
+ props.set(methodName, { t: "unknown" });
616
+ i = skipped;
617
+ if (src[i] === ",") {
618
+ i += 1;
619
+ continue;
620
+ }
621
+ if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
622
+ return null;
623
+ }
624
+ const value = parseValue(src, i + 1, depth + 1);
625
+ const after = value ? skipTrivia(src, value.end) : -1;
626
+ if (value && (src[after] === "," || src[after] === "}")) {
627
+ props.set(key, value.node);
628
+ i = after;
629
+ } else {
630
+ // Unparseable or only partially parsed expression (`x => a || b`):
631
+ // keep the keys we understand and skip this value.
632
+ const skipped = skipToValueEnd(src, i + 1);
633
+ if (skipped === null) return null;
634
+ props.set(key, { t: "unknown" });
635
+ i = skipped;
636
+ }
637
+ if (src[i] === ",") {
638
+ i += 1;
639
+ continue;
640
+ }
641
+ if (src[i] === "}") return { node: { t: "obj", props }, end: i + 1 };
642
+ return null;
643
+ }
644
+ }
645
+
646
+ function findBrace(src, start) {
647
+ let i = start;
648
+ while (i < src.length) {
649
+ const ch = src[i];
650
+ if (ch === '"' || ch === "'" || ch === "`") {
651
+ const parsed = parseString(src, i);
652
+ if (parsed) {
653
+ i = parsed.end;
654
+ continue;
655
+ }
656
+ }
657
+ if (ch === "{") return i;
658
+ if (ch === ";" || ch === "}") return i;
659
+ i += 1;
660
+ }
661
+ return i;
662
+ }
663
+
664
+ function getterValue(src, start, end, depth) {
665
+ const body = src.slice(start, end);
666
+ const returns = body.match(/\breturn\b/g) ?? [];
667
+ if (returns.length !== 1) return { t: "unknown" };
668
+ const at = start + body.indexOf("return") + "return".length;
669
+ const parsed = parseValue(src, at, depth + 1);
670
+ return parsed ? parsed.node : { t: "unknown" };
671
+ }
672
+
673
+ function skipToValueEnd(src, start) {
674
+ let i = start;
675
+ let depth = 0;
676
+ while (i < src.length) {
677
+ const ch = src[i];
678
+ if (ch === '"' || ch === "'" || ch === "`") {
679
+ const parsed = parseString(src, i);
680
+ if (parsed) {
681
+ i = parsed.end;
682
+ continue;
683
+ }
684
+ }
685
+ if (ch === "/" && src[i + 1] === "/") {
686
+ const nl = src.indexOf("\n", i);
687
+ i = nl < 0 ? src.length : nl + 1;
688
+ continue;
689
+ }
690
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
691
+ else if (ch === ")" || ch === "]") {
692
+ if (depth > 0) depth -= 1;
693
+ } else if (ch === "}") {
694
+ if (depth === 0) return i;
695
+ depth -= 1;
696
+ } else if (ch === "," && depth === 0) return i;
697
+ i += 1;
698
+ }
699
+ return null;
700
+ }
701
+
702
+ function parseArray(src, start, depth) {
703
+ const items = [];
704
+ let i = skipTrivia(src, start + 1);
705
+ if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
706
+ for (;;) {
707
+ const value = parseValue(src, i, depth + 1);
708
+ if (!value) return null;
709
+ items.push(value.node);
710
+ i = skipTrivia(src, value.end);
711
+ if (src[i] === ",") {
712
+ i = skipTrivia(src, i + 1);
713
+ if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
714
+ continue;
715
+ }
716
+ if (src[i] === "]") return { node: { t: "arr", items }, end: i + 1 };
717
+ return null;
718
+ }
719
+ }
720
+
721
+ function parseString(src, start) {
722
+ const quote = src[start];
723
+ if (quote === "`") return parseTemplate(src, start);
724
+ let i = start + 1;
725
+ let value = "";
726
+ while (i < src.length) {
727
+ const ch = src[i];
728
+ if (ch === "\\") {
729
+ const next = src[i + 1];
730
+ const map = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", "0": "\0" };
731
+ value += map[next] ?? next ?? "";
732
+ i += 2;
733
+ continue;
734
+ }
735
+ if (ch === quote) return { node: { t: "str", value }, end: i + 1 };
736
+ if (ch === "\n") return null;
737
+ value += ch;
738
+ i += 1;
739
+ }
740
+ return null;
741
+ }
742
+
743
+ // Template literals appear inside handler bodies (`Slept for ${n} seconds`).
744
+ // Their end must be found structurally: a naive scan confuses the closing and
745
+ // opening backticks of two different templates and unbalances the object parse.
746
+ function parseTemplate(src, start) {
747
+ let i = start + 1;
748
+ let value = "";
749
+ let dynamic = false;
750
+ while (i < src.length) {
751
+ const ch = src[i];
752
+ if (ch === "\\") {
753
+ value += src[i + 1] ?? "";
754
+ i += 2;
755
+ continue;
756
+ }
757
+ if (ch === "`") {
758
+ return dynamic ? { node: { t: "unknown" }, end: i + 1 } : { node: { t: "str", value }, end: i + 1 };
759
+ }
760
+ if (ch === "$" && src[i + 1] === "{") {
761
+ dynamic = true;
762
+ const end = skipBraced(src, i + 2);
763
+ if (end === null) return null;
764
+ i = end;
765
+ continue;
766
+ }
767
+ value += ch;
768
+ i += 1;
769
+ }
770
+ return null;
771
+ }
772
+
773
+ function skipBraced(src, start) {
774
+ let depth = 1;
775
+ let i = start;
776
+ while (i < src.length) {
777
+ const ch = src[i];
778
+ if (ch === '"' || ch === "'" || ch === "`") {
779
+ const parsed = parseString(src, i);
780
+ if (parsed) {
781
+ i = parsed.end;
782
+ continue;
783
+ }
784
+ }
785
+ if (ch === "{") depth += 1;
786
+ else if (ch === "}") {
787
+ depth -= 1;
788
+ if (depth === 0) return i + 1;
789
+ }
790
+ i += 1;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ function parseNumber(src, start) {
796
+ // Numeric separators (`50_000`) are common in real tool defaults.
797
+ const match = /^-?\d[\d_]*(?:\.\d[\d_]*)?(?:e[+-]?\d+)?/i.exec(src.slice(start, start + 40));
798
+ if (!match) return null;
799
+ return { node: { t: "num", value: Number(match[0].replace(/_/g, "")) }, end: start + match[0].length };
800
+ }
801
+
802
+ function parseChain(src, start, depth) {
803
+ let i = start;
804
+ const segments = [];
805
+ const head = readIdent(src, i);
806
+ if (!head) return null;
807
+ segments.push(head.name);
808
+ i = head.end;
809
+ for (;;) {
810
+ const j = skipTrivia(src, i);
811
+ if (src[j] === ".") {
812
+ const next = readIdent(src, j + 1);
813
+ if (!next) break;
814
+ segments.push(next.name);
815
+ i = next.end;
816
+ continue;
817
+ }
818
+ if (src[j] === "(") {
819
+ const parsed = parseArgs(src, j);
820
+ if (!parsed) return null;
821
+ const calls = [];
822
+ let end = parsed.end;
823
+ for (;;) {
824
+ const k = skipTrivia(src, end);
825
+ if (src[k] !== ".") break;
826
+ const method = readIdent(src, k + 1);
827
+ if (!method) break;
828
+ const l = skipTrivia(src, method.end);
829
+ if (src[l] !== "(") break;
830
+ const methodArgs = parseArgs(src, l);
831
+ if (!methodArgs) break;
832
+ calls.push({ name: method.name, args: methodArgs.items });
833
+ end = methodArgs.end;
834
+ }
835
+ return { node: { t: "call", callee: segments.join("."), args: parsed.items, calls }, end };
836
+ }
837
+ break;
838
+ }
839
+ return { node: { t: "ident", name: segments.join(".") }, end: i };
840
+ }
841
+
842
+ function parseArgs(src, start) {
843
+ const items = [];
844
+ let i = skipTrivia(src, start + 1);
845
+ if (src[i] === ")") return { items, end: i + 1 };
846
+ for (;;) {
847
+ let value = parseValue(src, i, 0);
848
+ let after = value ? skipTrivia(src, value.end) : -1;
849
+ if (value) {
850
+ // TypeScript wrappers: `{...} satisfies ToolDef<...>` and `x as T`.
851
+ if (isTypeWrapperAt(src, after)) {
852
+ const skipped = skipTypeExpression(src, after);
853
+ if (skipped === null) return null;
854
+ after = skipped;
855
+ }
856
+ if (src[after] === "," || src[after] === ")") {
857
+ items.push(value.node);
858
+ i = after;
859
+ } else {
860
+ // Binary expressions (`a || b`, `a ?? b`) parse only their first
861
+ // operand; treat the whole argument as unknown instead.
862
+ value = null;
863
+ }
864
+ }
865
+ if (!value) {
866
+ const skipped = skipArgument(src, i);
867
+ if (skipped === null) return null;
868
+ items.push(null);
869
+ i = skipped;
870
+ }
871
+ if (src[i] === ",") {
872
+ i = skipTrivia(src, i + 1);
873
+ continue;
874
+ }
875
+ if (src[i] === ")") return { items, end: i + 1 };
876
+ return null;
877
+ }
878
+ }
879
+
880
+ function isTypeWrapperAt(src, i) {
881
+ return (
882
+ (src.startsWith("satisfies", i) && !/[\w$]/.test(src[i + 9] ?? "")) ||
883
+ (src.startsWith("as", i) && /[\s<]/.test(src[i + 2] ?? ""))
884
+ );
885
+ }
886
+
887
+ // Skips an annotation after satisfies/as. Generic argument commas are not
888
+ // argument separators, so `<...>` is tracked as depth.
889
+ function skipTypeExpression(src, start) {
890
+ let i = start;
891
+ let paren = 0;
892
+ let angle = 0;
893
+ while (i < src.length) {
894
+ const ch = src[i];
895
+ if (ch === '"' || ch === "'" || ch === "`") {
896
+ const parsed = parseString(src, i);
897
+ if (parsed) {
898
+ i = parsed.end;
899
+ continue;
900
+ }
901
+ }
902
+ if (ch === "(" || ch === "[" || ch === "{") paren += 1;
903
+ else if (ch === ")" || ch === "]" || ch === "}") {
904
+ if (paren === 0) return i;
905
+ paren -= 1;
906
+ } else if (ch === "<") angle += 1;
907
+ else if (ch === ">" && angle > 0) angle -= 1;
908
+ else if (ch === "," && paren === 0 && angle === 0) return i;
909
+ i += 1;
910
+ }
911
+ return null;
912
+ }
913
+
914
+ // A handler argument (`async (args) => {...}`) is not a value we can parse; step
915
+ // over it so one unparseable parameter does not discard the whole definition.
916
+ function skipArgument(src, start) {
917
+ let i = start;
918
+ let depth = 0;
919
+ while (i < src.length) {
920
+ const ch = src[i];
921
+ if (ch === '"' || ch === "'" || ch === "`") {
922
+ const parsed = parseString(src, i);
923
+ if (parsed) {
924
+ i = parsed.end;
925
+ continue;
926
+ }
927
+ }
928
+ if (ch === "(" || ch === "[" || ch === "{") depth += 1;
929
+ else if (ch === ")" || ch === "]" || ch === "}") {
930
+ if (depth === 0) return ch === ")" ? i : null;
931
+ depth -= 1;
932
+ } else if (ch === "," && depth === 0) return i;
933
+ i += 1;
934
+ }
935
+ return null;
936
+ }
937
+
938
+ function readIdent(src, start) {
939
+ const match = /^[A-Za-z_$][\w$]*/.exec(src.slice(start, start + 120));
940
+ if (!match) return null;
941
+ return { name: match[0], end: start + match[0].length };
942
+ }
943
+
944
+ function skipTrivia(src, start) {
945
+ let i = start;
946
+ while (i < src.length) {
947
+ const ch = src[i];
948
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
949
+ i += 1;
950
+ continue;
951
+ }
952
+ if (ch === "/" && src[i + 1] === "/") {
953
+ const nl = src.indexOf("\n", i);
954
+ i = nl < 0 ? src.length : nl + 1;
955
+ continue;
956
+ }
957
+ if (ch === "/" && src[i + 1] === "*") {
958
+ const close = src.indexOf("*/", i + 2);
959
+ i = close < 0 ? src.length : close + 2;
960
+ continue;
961
+ }
962
+ return i;
963
+ }
964
+ return i;
965
+ }
package/lib/lint.js CHANGED
@@ -91,7 +91,11 @@ export function lintSuite(suite) {
91
91
  const description = (tool.description ?? "").trim();
92
92
  const template = `建议:\`${tool.name}:当用户需要____时使用。输入____,返回____。不适用于____。\``;
93
93
 
94
- if (!description) {
94
+ if (tool.descriptionSource === "runtime") {
95
+ // The description exists at runtime but is assembled in code, so static
96
+ // review cannot judge its wording. Not a defect.
97
+ add("info", "desc-runtime", tool.name, "description 由代码在运行时生成,静态不可评;请确认运行时输出包含触发时机与边界");
98
+ } else if (!description) {
95
99
  add("error", "desc-missing", tool.name, "工具没有 description,模型无法判断何时调用", { suggestion: template });
96
100
  } else {
97
101
  if (description.length < 20) {
@@ -107,8 +111,10 @@ export function lintSuite(suite) {
107
111
  }
108
112
  }
109
113
 
114
+ // Naming style is an ecosystem convention, not a model-facing defect:
115
+ // Claude-style PascalCase agents work fine on their own. Info only.
110
116
  if (!/^[a-z][a-z0-9_]*$/.test(tool.name)) {
111
- add("warn", "name-style", tool.name, "工具名建议用小写 snake_case");
117
+ add("info", "name-style", tool.name, "工具名不是小写 snake_case;对外分发(MCP/插件市场)时建议统一");
112
118
  }
113
119
  if (GENERIC_NAMES.has(tool.name)) {
114
120
  add("warn", "name-generic", tool.name, "工具名过于通用,容易和其它工具混淆", {
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
- const MAX_FILES = 5000;
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
- await walk(absRoot, 0, files);
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(report, tools, { source, origin, confidence = "high", note, meta }) {
258
- const existing = new Set(report.tools.map((tool) => tool.name));
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 || existing.has(tool.name)) continue;
262
- existing.add(tool.name);
263
- report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
264
- added.push(tool.name);
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,
@@ -290,7 +348,8 @@ export function buildSuiteDraft(report) {
290
348
  tools: report.tools.map((tool) => ({
291
349
  name: tool.name,
292
350
  description: tool.description,
293
- parameters: tool.parameters
351
+ parameters: tool.parameters,
352
+ ...(tool.descriptionSource ? { descriptionSource: tool.descriptionSource } : {})
294
353
  })),
295
354
  cases: []
296
355
  };
@@ -387,8 +446,11 @@ export function formatScanSummary(report) {
387
446
  return lines.join("\n");
388
447
  }
389
448
 
390
- async function walk(dir, depth, files) {
391
- if (depth > MAX_DEPTH || files.length >= MAX_FILES) return;
449
+ async function walk(dir, depth, files, state) {
450
+ if (depth > MAX_DEPTH || files.length >= MAX_FILES) {
451
+ if (files.length >= MAX_FILES) state.truncated = true;
452
+ return;
453
+ }
392
454
  let entries;
393
455
  try {
394
456
  entries = await readdir(dir, { withFileTypes: true });
@@ -397,11 +459,14 @@ async function walk(dir, depth, files) {
397
459
  }
398
460
  entries.sort((a, b) => a.name.localeCompare(b.name));
399
461
  for (const entry of entries) {
400
- if (files.length >= MAX_FILES) return;
462
+ if (files.length >= MAX_FILES) {
463
+ state.truncated = true;
464
+ return;
465
+ }
401
466
  const full = join(dir, entry.name);
402
467
  if (entry.isDirectory()) {
403
468
  if (SKIP_DIRS.has(entry.name)) continue;
404
- await walk(full, depth + 1, files);
469
+ await walk(full, depth + 1, files, state);
405
470
  } else if (entry.isFile()) {
406
471
  files.push(full);
407
472
  }
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)) {
@@ -54,7 +54,8 @@ async function normalizeTool(entry, baseDir) {
54
54
  description: entry.description ?? "",
55
55
  parameters: entry.parameters ?? entry.input ?? EMPTY_SCHEMA,
56
56
  source: null,
57
- sideEffects: normalizeSideEffects(entry.sideEffects ?? entry.side_effects)
57
+ sideEffects: normalizeSideEffects(entry.sideEffects ?? entry.side_effects),
58
+ ...(entry.descriptionSource === "runtime" ? { descriptionSource: "runtime" } : {})
58
59
  };
59
60
  }
60
61
 
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), { allowEmptyCases: true });
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), { allowEmptyCases: true });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",
@@ -25,7 +25,7 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "scripts": {
28
- "test": "node --test \"test/**/*.test.js\"",
28
+ "test": "node --test --test-concurrency=1 \"test/**/*.test.js\"",
29
29
  "eval": "node mf.js eval",
30
30
  "eval:mock": "node mf.js eval --models mock --show-cases",
31
31
  "pack:check": "npm pack --dry-run",