modelfunction-cli 0.1.3 → 0.3.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 +35 -10
- package/fixtures/suite.json +5 -1
- package/lib/ablate.js +2 -1
- package/lib/cases.js +121 -29
- package/lib/cluster.js +158 -0
- package/lib/compare.js +84 -0
- package/lib/lint.js +194 -20
- package/lib/mcp-server.js +15 -2
- package/lib/profile.js +304 -0
- package/lib/scan.js +188 -6
- package/lib/score.js +22 -0
- package/lib/suite.js +19 -0
- package/mf.js +261 -46
- package/package.json +1 -1
package/lib/scan.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { basename, join, relative, resolve } from "node:path";
|
|
2
|
+
import { basename, extname, join, relative, resolve } from "node:path";
|
|
3
3
|
|
|
4
4
|
const SKIP_DIRS = new Set([
|
|
5
5
|
"node_modules",
|
|
@@ -43,6 +43,10 @@ const OPENAPI_PATTERN = /^(openapi|swagger)[^/]*\.json$/i;
|
|
|
43
43
|
const SKILL_FILE = "skill.md";
|
|
44
44
|
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py"]);
|
|
45
45
|
|
|
46
|
+
// Test fixtures and mocks use the same APIs as real tool definitions, so any
|
|
47
|
+
// match there is noise for a tool inventory.
|
|
48
|
+
const TEST_PATH = /(^|[\\/])(__tests__|tests?)([\\/])|\.(test|spec)\.[cm]?[jt]sx?$|(^|[\\/])test_[^\\/]+\.py$|_test\.py$/i;
|
|
49
|
+
|
|
46
50
|
const CODE_PATTERNS = [
|
|
47
51
|
{ label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
48
52
|
{ label: "server.tool", confidence: "medium", re: /\.tool\s*\(\s*["'`]([\w.-]+)["'`]/ },
|
|
@@ -209,7 +213,21 @@ export async function scanProject(root = ".") {
|
|
|
209
213
|
if (!info || info.size > MAX_FILE_BYTES) continue;
|
|
210
214
|
const text = await readText(file, report);
|
|
211
215
|
if (!text) continue;
|
|
212
|
-
|
|
216
|
+
if (name.endsWith(".py") && !TEST_PATH.test(rel)) {
|
|
217
|
+
const pythonTools = scanPythonTools(text);
|
|
218
|
+
if (pythonTools.length > 0) {
|
|
219
|
+
addTools(report, pythonTools, {
|
|
220
|
+
source: rel,
|
|
221
|
+
origin: "py-tool",
|
|
222
|
+
confidence: "medium",
|
|
223
|
+
note: "从 @tool 装饰器与类型注解提取,需人工确认"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (TEST_PATH.test(rel)) continue;
|
|
228
|
+
// Only matches that carry a tool name are actionable; a bare `input_schema:`
|
|
229
|
+
// in API plumbing is not a tool and only inflates the report.
|
|
230
|
+
const matches = scanCode(text).filter((match) => match.name);
|
|
213
231
|
for (const match of matches) {
|
|
214
232
|
report.codeMatches.push({
|
|
215
233
|
source: rel,
|
|
@@ -236,13 +254,13 @@ export async function scanProject(root = ".") {
|
|
|
236
254
|
return report;
|
|
237
255
|
}
|
|
238
256
|
|
|
239
|
-
export function addTools(report, tools, { source, origin, confidence = "high", note }) {
|
|
257
|
+
export function addTools(report, tools, { source, origin, confidence = "high", note, meta }) {
|
|
240
258
|
const existing = new Set(report.tools.map((tool) => tool.name));
|
|
241
259
|
const added = [];
|
|
242
260
|
for (const tool of tools) {
|
|
243
261
|
if (!tool?.name || existing.has(tool.name)) continue;
|
|
244
262
|
existing.add(tool.name);
|
|
245
|
-
report.tools.push({ ...tool, source, origin });
|
|
263
|
+
report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
|
|
246
264
|
added.push(tool.name);
|
|
247
265
|
}
|
|
248
266
|
report.sources.push({
|
|
@@ -266,7 +284,7 @@ export function redactReport(report) {
|
|
|
266
284
|
}
|
|
267
285
|
|
|
268
286
|
export function buildSuiteDraft(report) {
|
|
269
|
-
|
|
287
|
+
const draft = {
|
|
270
288
|
name: `${basename(report.root)}-draft`,
|
|
271
289
|
description: "由 mf scan 生成。请人工确认工具描述与参数,并补充 cases 后再运行 mf eval。",
|
|
272
290
|
tools: report.tools.map((tool) => ({
|
|
@@ -276,6 +294,36 @@ export function buildSuiteDraft(report) {
|
|
|
276
294
|
})),
|
|
277
295
|
cases: []
|
|
278
296
|
};
|
|
297
|
+
if (report.skills.length > 0) {
|
|
298
|
+
draft.skills = report.skills.map((skill) => ({ name: skill.name, description: skill.description }));
|
|
299
|
+
}
|
|
300
|
+
// Where a tool came from is the only mount hint the scan can observe without
|
|
301
|
+
// guessing: one MCP server or one spec file is normally one thing agents mount.
|
|
302
|
+
const profiles = groupBySource(report.tools);
|
|
303
|
+
if (profiles) draft.profiles = profiles;
|
|
304
|
+
return draft;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function groupBySource(tools) {
|
|
308
|
+
const groups = new Map();
|
|
309
|
+
const owner = new Map();
|
|
310
|
+
for (const tool of tools) {
|
|
311
|
+
const key = tool.provider ?? tool.source ?? "";
|
|
312
|
+
if (!key) continue;
|
|
313
|
+
let label = profileLabel(key);
|
|
314
|
+
if (owner.has(label) && owner.get(label) !== key) label = String(key).replace(/[\\/]+/g, "-");
|
|
315
|
+
owner.set(label, key);
|
|
316
|
+
if (!groups.has(label)) groups.set(label, []);
|
|
317
|
+
groups.get(label).push(tool.name);
|
|
318
|
+
}
|
|
319
|
+
if (groups.size < 2) return null;
|
|
320
|
+
return Object.fromEntries([...groups.entries()].sort(([a], [b]) => a.localeCompare(b)));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function profileLabel(key) {
|
|
324
|
+
const text = String(key);
|
|
325
|
+
if (!/[\\/]|\.(json|ya?ml|toml|js|mjs|ts|py)$/i.test(text)) return text;
|
|
326
|
+
return basename(text, extname(text)) || text;
|
|
279
327
|
}
|
|
280
328
|
|
|
281
329
|
export function formatScanSummary(report) {
|
|
@@ -308,7 +356,7 @@ export function formatScanSummary(report) {
|
|
|
308
356
|
lines.push(
|
|
309
357
|
"",
|
|
310
358
|
`tools: ${report.tools.length}`,
|
|
311
|
-
`skills: ${report.skills.length}
|
|
359
|
+
`skills: ${report.skills.length}(仅静态检查)`,
|
|
312
360
|
`mcp servers: ${report.mcpServers.length}${probed > 0 ? `(已 probe ${probed} 个)` : "(需 --probe 才能拿到工具清单)"}`,
|
|
313
361
|
`code match: ${report.codeMatches.length}(low confidence,需人工确认)`
|
|
314
362
|
);
|
|
@@ -480,6 +528,140 @@ function parseFrontmatter(text) {
|
|
|
480
528
|
return meta;
|
|
481
529
|
}
|
|
482
530
|
|
|
531
|
+
// LangChain-style tool definitions: @tool above a typed function. The runtime
|
|
532
|
+
// builds the JSON schema from the same annotations, so this is the real schema,
|
|
533
|
+
// not a guess — only the description can drift (first docstring line vs args_schema).
|
|
534
|
+
export function scanPythonTools(text) {
|
|
535
|
+
const lines = text.split(/\r?\n/);
|
|
536
|
+
const tools = [];
|
|
537
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
538
|
+
if (!/^\s*@tool\b/.test(lines[i])) continue;
|
|
539
|
+
let j = i + 1;
|
|
540
|
+
while (j < lines.length && j - i <= 4 && !/^\s*(async\s+)?def\s+\w+\s*\(/.test(lines[j])) j += 1;
|
|
541
|
+
const defMatch = lines[j]?.match(/^\s*(?:async\s+)?def\s+(\w+)\s*\(/);
|
|
542
|
+
if (!defMatch) continue;
|
|
543
|
+
const signature = collectSignature(lines, j, defMatch[0].length);
|
|
544
|
+
if (signature === null) continue;
|
|
545
|
+
const properties = {};
|
|
546
|
+
const required = [];
|
|
547
|
+
for (const part of splitPyParams(signature.text)) {
|
|
548
|
+
const param = parsePyParam(part);
|
|
549
|
+
if (!param) continue;
|
|
550
|
+
properties[param.name] = param.schema;
|
|
551
|
+
if (param.required) required.push(param.name);
|
|
552
|
+
}
|
|
553
|
+
tools.push({
|
|
554
|
+
name: defMatch[1],
|
|
555
|
+
description: pythonDocstring(lines, signature.endLine),
|
|
556
|
+
parameters: {
|
|
557
|
+
type: "object",
|
|
558
|
+
properties,
|
|
559
|
+
...(required.length > 0 ? { required } : {})
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
i = signature.endLine;
|
|
563
|
+
}
|
|
564
|
+
return tools;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function collectSignature(lines, startLine, offset) {
|
|
568
|
+
let depth = 1;
|
|
569
|
+
let out = "";
|
|
570
|
+
for (let i = startLine; i < lines.length; i += 1) {
|
|
571
|
+
const line = lines[i];
|
|
572
|
+
for (let c = i === startLine ? offset : 0; c < line.length; c += 1) {
|
|
573
|
+
const ch = line[c];
|
|
574
|
+
if (ch === "(") depth += 1;
|
|
575
|
+
else if (ch === ")") {
|
|
576
|
+
depth -= 1;
|
|
577
|
+
if (depth === 0) return { text: out, endLine: i };
|
|
578
|
+
}
|
|
579
|
+
out += ch;
|
|
580
|
+
}
|
|
581
|
+
out += "\n";
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function splitPyParams(text) {
|
|
587
|
+
const parts = [];
|
|
588
|
+
let depth = 0;
|
|
589
|
+
let quote = null;
|
|
590
|
+
let current = "";
|
|
591
|
+
for (const ch of text) {
|
|
592
|
+
if (quote) {
|
|
593
|
+
current += ch;
|
|
594
|
+
if (ch === quote) quote = null;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (ch === '"' || ch === "'") {
|
|
598
|
+
quote = ch;
|
|
599
|
+
current += ch;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (ch === "(" || ch === "[" || ch === "{") depth += 1;
|
|
603
|
+
if (ch === ")" || ch === "]" || ch === "}") depth -= 1;
|
|
604
|
+
if (ch === "," && depth === 0) {
|
|
605
|
+
parts.push(current);
|
|
606
|
+
current = "";
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
current += ch;
|
|
610
|
+
}
|
|
611
|
+
if (current.trim()) parts.push(current);
|
|
612
|
+
return parts.map((part) => part.trim()).filter(Boolean);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const PY_TYPES = {
|
|
616
|
+
str: "string",
|
|
617
|
+
int: "number",
|
|
618
|
+
float: "number",
|
|
619
|
+
bool: "boolean",
|
|
620
|
+
list: "array",
|
|
621
|
+
List: "array",
|
|
622
|
+
dict: "object",
|
|
623
|
+
Dict: "object"
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
function parsePyParam(part) {
|
|
627
|
+
const eq = part.indexOf("=");
|
|
628
|
+
const head = eq === -1 ? part : part.slice(0, eq);
|
|
629
|
+
const hasDefault = eq !== -1;
|
|
630
|
+
const colon = head.indexOf(":");
|
|
631
|
+
const name = (colon === -1 ? head : head.slice(0, colon)).trim().replace(/^\*+/, "");
|
|
632
|
+
if (!name || name === "self" || name === "cls") return null;
|
|
633
|
+
const typeText = colon === -1 ? "" : head.slice(colon + 1).trim();
|
|
634
|
+
const description = (typeText.match(/Annotated\s*\[[^,]+,\s*["']([^"']+)["']/) ?? [])[1] ?? "";
|
|
635
|
+
const schema = { type: pyType(typeText) };
|
|
636
|
+
if (description) schema.description = description;
|
|
637
|
+
return { name, schema, required: !hasDefault };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function pyType(typeText) {
|
|
641
|
+
const core = typeText
|
|
642
|
+
.replace(/^Annotated\s*\[\s*([^,\]]+).*/s, "$1")
|
|
643
|
+
.replace(/^Optional\s*\[\s*(.*?)\s*\]$/s, "$1")
|
|
644
|
+
.trim();
|
|
645
|
+
return PY_TYPES[core] ?? (core.endsWith("List") || core.startsWith("list") ? "array" : "string");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function pythonDocstring(lines, fromLine) {
|
|
649
|
+
for (let i = fromLine + 1; i < Math.min(lines.length, fromLine + 6); i += 1) {
|
|
650
|
+
const match = lines[i].match(/^\s*(?:[rRuU])?("""|''')\s*(.*)$/);
|
|
651
|
+
if (!match) continue;
|
|
652
|
+
const quote = match[1];
|
|
653
|
+
const rest = match[2];
|
|
654
|
+
const sameLine = rest.includes(quote) ? rest.split(quote)[0] : rest;
|
|
655
|
+
if (sameLine.trim()) return sameLine.trim();
|
|
656
|
+
for (let k = i + 1; k < lines.length; k += 1) {
|
|
657
|
+
const text = lines[k].trim();
|
|
658
|
+
if (!text) continue;
|
|
659
|
+
return text.startsWith(quote) ? "" : text;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return "";
|
|
663
|
+
}
|
|
664
|
+
|
|
483
665
|
function scanCode(text) {
|
|
484
666
|
const matches = [];
|
|
485
667
|
for (const pattern of CODE_PATTERNS) {
|
package/lib/score.js
CHANGED
|
@@ -54,6 +54,28 @@ export function matchArgs(actualArgs, expectedArgs) {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// Even at temperature 0 providers can flip a tool call between identical
|
|
58
|
+
// requests. Repeats exist so the scorecard can show that instead of baking one
|
|
59
|
+
// sample into the number.
|
|
60
|
+
export function pickRepresentative(runs) {
|
|
61
|
+
const signature = (run) => (run?.ok ? run.toolCalls?.[0]?.name ?? "(no tool)" : `error: ${run?.error ?? ""}`);
|
|
62
|
+
const counts = new Map();
|
|
63
|
+
for (const run of runs) {
|
|
64
|
+
const key = signature(run);
|
|
65
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
let best = null;
|
|
68
|
+
for (const [key, count] of counts) {
|
|
69
|
+
if (!best || count > best.count) best = { key, count };
|
|
70
|
+
}
|
|
71
|
+
const chosen = runs.find((run) => signature(run) === best.key) ?? runs[0];
|
|
72
|
+
return {
|
|
73
|
+
result: chosen,
|
|
74
|
+
unstable: counts.size > 1,
|
|
75
|
+
outcomes: [...counts.entries()].map(([outcome, count]) => ({ outcome, count }))
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
57
79
|
export function scoreModel({ model, cases, results, pricing }) {
|
|
58
80
|
const rows = cases.map((testCase, index) => {
|
|
59
81
|
const result = results[index];
|
package/lib/suite.js
CHANGED
|
@@ -28,6 +28,8 @@ export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
|
|
|
28
28
|
path: abs,
|
|
29
29
|
tools,
|
|
30
30
|
cases,
|
|
31
|
+
profiles: raw.profiles ?? null,
|
|
32
|
+
skills: Array.isArray(raw.skills) ? raw.skills.map(normalizeSkill) : [],
|
|
31
33
|
execution:
|
|
32
34
|
raw.execution && typeof raw.execution === "object" && !Array.isArray(raw.execution)
|
|
33
35
|
? { ...raw.execution }
|
|
@@ -56,6 +58,23 @@ async function normalizeTool(entry, baseDir) {
|
|
|
56
58
|
};
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Skills carry no parameter schema in the prompt-injected form (SKILL.md
|
|
62
|
+
// frontmatter), so the only checkable surface is name + description. Both the
|
|
63
|
+
// bare-name shorthand and the full form are accepted.
|
|
64
|
+
function normalizeSkill(entry, index) {
|
|
65
|
+
if (typeof entry === "string") {
|
|
66
|
+
const name = entry.trim();
|
|
67
|
+
if (!name) throw new Error(`skill #${index + 1}: name must be a non-empty string`);
|
|
68
|
+
return { name, description: "" };
|
|
69
|
+
}
|
|
70
|
+
if (entry && typeof entry === "object") {
|
|
71
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
72
|
+
if (!name) throw new Error(`skill #${index + 1}: "name" must be a non-empty string`);
|
|
73
|
+
return { name, description: typeof entry.description === "string" ? entry.description : "" };
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`skill #${index + 1}: must be a name or {name, description}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
59
78
|
function normalizeCase(entry, index, toolNames) {
|
|
60
79
|
const id = entry.id ?? `case_${index + 1}`;
|
|
61
80
|
if (typeof entry.input !== "string" || entry.input.trim() === "") {
|