modelfunction-cli 0.4.0 → 0.4.2

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
@@ -69,6 +69,7 @@ INTERNAL_LLM_KEY=... mf eval --suite mf.suite.json \
69
69
  | `mf case add` | 写入用例(默认 `source=agent`、`reviewed=false`) |
70
70
  | `mf coverage` | 每工具用例数、参数覆盖、负例比例、疑似混淆对;有 `profiles` 时按组统计 |
71
71
  | `mf lint` | 静态检查工具描述与参数 schema,以及 skills 的 name/description(不调用模型);重复规则自动合并成一条;有 `profiles` 时给出「这些组不要合并挂载」 |
72
+ | `mf check` | 安装门禁:一个新能力 × 已装能力(`--manifest` + `--installed`),纯静态、返回 pass/warn、不阻断;也可 `import { check } from "modelfunction-cli"` |
72
73
  | `mf compare` | 跨版本比较、干扰归因、混淆矩阵;两份报告都有分组时按组对比 |
73
74
  | `mf ablate` | 留一法:去掉某个工具再跑一遍,量化它是否干扰其他工具;`--profile` 限定在某个挂载组内 |
74
75
  | `mf cluster` | 聚类工具,建议挂载分组;参数形状拆散的同一族(如 `sessions_*`)列为「同族候选」;`--out` 把分组写成套件的 `profiles` |
package/lib/check.js ADDED
@@ -0,0 +1,118 @@
1
+ // Install-gate entry point: judge one new capability against what is already
2
+ // installed. Static only — no model call, no network, no file system, no state.
3
+ // Quality findings are advisory: the verdict is pass/warn, never "block".
4
+ import { lintSuite } from "./lint.js";
5
+
6
+ export function check({ manifest, installed = [] } = {}) {
7
+ const target = normalizeEntry(manifest);
8
+ if (!target) throw new Error('check: manifest 需要非空 "name"');
9
+ const pool = [];
10
+ if (Array.isArray(installed)) pool.push(...installed);
11
+ else if (installed && typeof installed === "object") {
12
+ pool.push(...(installed.tools ?? []), ...(installed.skills ?? []));
13
+ }
14
+ const entries = pool.map(normalizeEntry).filter(Boolean);
15
+
16
+ const tools = [];
17
+ const skills = [];
18
+ for (const entry of entries) (isToolLike(entry) ? tools : skills).push(entry);
19
+ (isToolLike(target) ? tools : skills).push(target);
20
+
21
+ const report = lintSuite({ tools, skills });
22
+ const findings = report.findings
23
+ .filter((finding) => touches(finding, target.name))
24
+ .map((finding) => shape(finding, target.name));
25
+ const summary = { error: 0, warn: 0, info: 0 };
26
+ for (const finding of findings) summary[finding.severity] += 1;
27
+ const family = familyOf(report.findings.filter((finding) => finding.pair), target.name);
28
+
29
+ return {
30
+ verdict: summary.error + summary.warn > 0 ? "warn" : "pass",
31
+ summary,
32
+ findings,
33
+ ...(family.length > 1 ? { family } : {})
34
+ };
35
+ }
36
+
37
+ function normalizeEntry(entry) {
38
+ if (!entry || typeof entry !== "object") return null;
39
+ const name = typeof entry.name === "string" ? entry.name.trim() : "";
40
+ if (!name) return null;
41
+ const parameters = entry.parameters ?? entry.inputSchema ?? entry.input ?? null;
42
+ return {
43
+ name,
44
+ description: typeof entry.description === "string" ? entry.description : "",
45
+ parameters: parameters && typeof parameters === "object" ? parameters : null
46
+ };
47
+ }
48
+
49
+ // A capability with a parameter schema is a tool; name + description alone is
50
+ // a prompt-injected skill (SKILL.md form).
51
+ function isToolLike(entry) {
52
+ return entry.parameters !== null;
53
+ }
54
+
55
+ function touches(finding, name) {
56
+ if (finding.kind === "skill") {
57
+ if (finding.skill === name) return true;
58
+ return Array.isArray(finding.pair) && finding.pair.includes(name);
59
+ }
60
+ if (finding.tool === name) return true;
61
+ return Array.isArray(finding.pair) && finding.pair.includes(name);
62
+ }
63
+
64
+ function shape(finding, name) {
65
+ const other = (finding.pair ?? []).find((item) => item !== name);
66
+ return {
67
+ severity: finding.severity,
68
+ code: finding.code,
69
+ subject: name,
70
+ ...(finding.param ? { param: finding.param } : {}),
71
+ ...(other ? { with: other } : {}),
72
+ message: finding.message,
73
+ ...(finding.suggestion ? { suggestion: finding.suggestion } : {})
74
+ };
75
+ }
76
+
77
+ // The connected family around the manifest — what an installer shows as
78
+ // "these installed capabilities compete with the new one".
79
+ function familyOf(pairs, name) {
80
+ const edges = new Map();
81
+ const link = (a, b) => {
82
+ if (!edges.has(a)) edges.set(a, new Set());
83
+ edges.get(a).add(b);
84
+ };
85
+ for (const finding of pairs) {
86
+ const [a, b] = finding.pair;
87
+ link(a, b);
88
+ link(b, a);
89
+ }
90
+ if (!edges.has(name)) return [name];
91
+ const seen = new Set([name]);
92
+ const queue = [name];
93
+ while (queue.length > 0) {
94
+ const current = queue.shift();
95
+ for (const next of edges.get(current) ?? []) {
96
+ if (seen.has(next)) continue;
97
+ seen.add(next);
98
+ queue.push(next);
99
+ }
100
+ }
101
+ return [...seen].sort();
102
+ }
103
+
104
+ export function formatCheck(result) {
105
+ const lines = [
106
+ `verdict: ${result.verdict}(error ${result.summary.error} / warn ${result.summary.warn} / info ${result.summary.info})`
107
+ ];
108
+ if (result.findings.length === 0) lines.push("没有发现问题");
109
+ for (const finding of result.findings) {
110
+ const param = finding.param ? `.${finding.param}` : "";
111
+ const withWhat = finding.with ? ` × ${finding.with}` : "";
112
+ lines.push(`[${finding.severity}] ${finding.code} ${finding.subject}${param}${withWhat}`);
113
+ lines.push(` ${finding.message}`);
114
+ if (finding.suggestion) lines.push(` ${finding.suggestion}`);
115
+ }
116
+ if (result.family) lines.push("", `族:${result.family.join(", ")}`);
117
+ return lines.join("\n");
118
+ }
package/lib/js-tools.js CHANGED
@@ -115,7 +115,11 @@ function toolFromObject(node, ctx, preset = {}) {
115
115
  node.props.has("isEnabled") ||
116
116
  node.props.has("searchHint");
117
117
  if (!description && Object.keys(schema.properties ?? {}).length === 0 && !toolish) return undefined;
118
- return { name, description, parameters: schema };
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" } : {}) };
119
123
  }
120
124
 
121
125
  function isSchema(value) {
@@ -601,7 +605,14 @@ function parseObject(src, start, depth) {
601
605
  const skipped = skipToValueEnd(src, i);
602
606
  if (skipped === null) {
603
607
  return null; }
604
- props.set(key, { t: "unknown" });
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" });
605
616
  i = skipped;
606
617
  if (src[i] === ",") {
607
618
  i += 1;
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/scan.js CHANGED
@@ -50,6 +50,9 @@ const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".py"]);
50
50
  // Test fixtures and mocks use the same APIs as real tool definitions, so any
51
51
  // match there is noise for a tool inventory.
52
52
  const TEST_PATH = /(^|[\\/])(__tests__|tests?)([\\/])|\.(test|spec)\.[cm]?[jt]sx?$|(^|[\\/])test_[^\\/]+\.py$|_test\.py$/i;
53
+ // Dev harnesses shape objects like tools (`{ name: "ping", inputSchema }`) but
54
+ // are not part of the agent's tool surface.
55
+ const DEV_PATH = /(^|[\\/])(scripts?|bench|benchmarks?|dev|examples?|fixtures?|mocks?)([\\/])|[-_.](runner|scan|bench|fixture|mock)\.[cm]?[jt]sx?$/i;
53
56
 
54
57
  const CODE_PATTERNS = [
55
58
  { label: "registerTool", confidence: "medium", re: /registerTool\s*\(\s*["'`]([\w.-]+)["'`]/ },
@@ -238,7 +241,7 @@ export async function scanProject(root = ".") {
238
241
  });
239
242
  }
240
243
  }
241
- if (!name.endsWith(".py") && !TEST_PATH.test(rel)) {
244
+ if (!name.endsWith(".py") && !TEST_PATH.test(rel) && !DEV_PATH.test(rel)) {
242
245
  for (const match of text.matchAll(/(?:export\s+)?const\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(['"])([^'"\n]{1,200})\2/g)) {
243
246
  if (!constIndex.has(match[1])) constIndex.set(match[1], match[3]);
244
247
  }
@@ -348,7 +351,8 @@ export function buildSuiteDraft(report) {
348
351
  tools: report.tools.map((tool) => ({
349
352
  name: tool.name,
350
353
  description: tool.description,
351
- parameters: tool.parameters
354
+ parameters: tool.parameters,
355
+ ...(tool.descriptionSource ? { descriptionSource: tool.descriptionSource } : {})
352
356
  })),
353
357
  cases: []
354
358
  };
package/lib/suite.js CHANGED
@@ -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
@@ -10,6 +10,7 @@ import { probeStdioServer } from "./lib/mcp-probe.js";
10
10
  import { loadAdapterTools } from "./lib/adapter-import.js";
11
11
  import { addCases, computeCoverage, formatCoverage } from "./lib/cases.js";
12
12
  import { lintSuite, formatLint } from "./lib/lint.js";
13
+ import { check, formatCheck } from "./lib/check.js";
13
14
  import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
14
15
  import { runExecutionChecks } from "./lib/eval-exec.js";
15
16
  import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
@@ -51,6 +52,7 @@ Usage:
51
52
  mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
52
53
  mf coverage [--suite <file>] [--json]
53
54
  mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
55
+ mf check --manifest <file> [--installed <file>] [--json] # 安装门禁:新能力 × 已装能力
54
56
  mf compare <before.json> <after.json> [--json]
55
57
  mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
56
58
  mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
@@ -425,6 +427,19 @@ export async function runSandboxCheck(options, io = console) {
425
427
  return 0;
426
428
  }
427
429
 
430
+ // Advisory install gate: static only, never blocks. Exit code stays 0 for a
431
+ // warn verdict so a platform can log it without failing the install.
432
+ export async function runCheck(options, io = console) {
433
+ if (!options.manifest) throw new Error("--manifest <file> 必填(新能力的 [name, description, parameters?])");
434
+ const manifest = JSON.parse(await readFile(resolve(options.manifest), "utf8"));
435
+ const installed = options.installed
436
+ ? JSON.parse(await readFile(resolve(options.installed), "utf8"))
437
+ : [];
438
+ const result = check({ manifest, installed });
439
+ io.log(options.json ? JSON.stringify(result, null, 2) : formatCheck(result));
440
+ return 0;
441
+ }
442
+
428
443
  export async function runLint(options, io = console) {
429
444
  const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
430
445
  allowEmptyCases: true,
@@ -669,6 +684,8 @@ function parseArgs(argv) {
669
684
  reviewed: false,
670
685
  dryRun: false,
671
686
  tool: null,
687
+ manifest: null,
688
+ installed: null,
672
689
  threshold: 0.5,
673
690
  maxSize: 10,
674
691
  baseline: null,
@@ -728,6 +745,8 @@ function parseArgs(argv) {
728
745
  case "--reviewed": options.reviewed = true; break;
729
746
  case "--dry-run": options.dryRun = true; break;
730
747
  case "--tool": options.tool = value(); break;
748
+ case "--manifest": options.manifest = resolve(value()); break;
749
+ case "--installed": options.installed = resolve(value()); break;
731
750
  case "--threshold": {
732
751
  const raw = Number(value());
733
752
  if (!Number.isFinite(raw) || raw <= 0 || raw > 1) throw new Error("--threshold expects a ratio between 0 and 1");
@@ -821,7 +840,7 @@ if (isMain) {
821
840
  }
822
841
  } else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
823
842
  process.stdout.write(USAGE);
824
- } else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "cluster", "exec"].includes(argv[0])) {
843
+ } else if (!["eval", "scan", "case", "coverage", "lint", "check", "ablate", "cluster", "exec"].includes(argv[0])) {
825
844
  process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
826
845
  process.exit(1);
827
846
  } else if (argv[0] === "case" && argv[1] !== "add") {
@@ -844,6 +863,8 @@ if (isMain) {
844
863
  ? await runCoverage(options)
845
864
  : argv[0] === "lint"
846
865
  ? await runLint(options)
866
+ : argv[0] === "check"
867
+ ? await runCheck(options)
847
868
  : argv[0] === "ablate"
848
869
  ? await runAblate(options)
849
870
  : argv[0] === "cluster"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",
@@ -25,12 +25,15 @@
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",
32
32
  "prepublishOnly": "npm test"
33
33
  },
34
+ "exports": {
35
+ ".": "./lib/check.js"
36
+ },
34
37
  "engines": {
35
38
  "node": ">=20"
36
39
  },