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 +1 -0
- package/lib/check.js +118 -0
- package/lib/js-tools.js +13 -2
- package/lib/lint.js +8 -2
- package/lib/scan.js +6 -2
- package/lib/suite.js +2 -1
- package/mf.js +22 -1
- package/package.json +5 -2
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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("
|
|
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.
|
|
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
|
},
|