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/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
- const matches = scanCode(text);
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
- return {
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() === "") {