modelfunction-cli 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +74 -2
- package/fixtures/models.json +39 -0
- package/fixtures/suite.json +150 -0
- package/lib/ablate.js +120 -0
- package/lib/adapter-import.js +24 -0
- package/lib/adapters/mock.js +143 -0
- package/lib/adapters/openai.js +95 -0
- package/lib/cases.js +188 -0
- package/lib/compare.js +220 -0
- package/lib/eval-exec.js +140 -0
- package/lib/exec.js +265 -0
- package/lib/fingerprint.js +52 -0
- package/lib/fsutil.js +7 -0
- package/lib/lint.js +110 -0
- package/lib/mcp-probe.js +152 -0
- package/lib/mcp-server.js +216 -0
- package/lib/report.js +119 -0
- package/lib/runtime/harness.mjs +145 -0
- package/lib/runtime/harness.py +373 -0
- package/lib/sandbox-check.js +166 -0
- package/lib/scan.js +510 -0
- package/lib/score.js +137 -0
- package/lib/suite.js +117 -0
- package/mf.js +636 -0
- package/package.json +38 -4
- package/skills/mf-cases/SKILL.md +58 -0
package/lib/cases.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadSuite } from "./suite.js";
|
|
4
|
+
import { writeFileAtomic } from "./fsutil.js";
|
|
5
|
+
|
|
6
|
+
const MIN_CASES_PER_TOOL = 3;
|
|
7
|
+
const MIN_NEGATIVE_RATIO = 0.2;
|
|
8
|
+
const PAIR_SIMILARITY = 0.35;
|
|
9
|
+
|
|
10
|
+
export async function addCases(suitePath, incoming, { source = "agent", reviewed = false, dryRun = false } = {}) {
|
|
11
|
+
const abs = resolve(suitePath);
|
|
12
|
+
const raw = JSON.parse(await readFile(abs, "utf8"));
|
|
13
|
+
const suite = await loadSuite(abs, { allowEmptyCases: true });
|
|
14
|
+
const tools = new Map(suite.tools.map((tool) => [tool.name, tool]));
|
|
15
|
+
const ids = new Set(suite.cases.map((testCase) => testCase.id));
|
|
16
|
+
|
|
17
|
+
const added = [];
|
|
18
|
+
const rejected = [];
|
|
19
|
+
const warnings = [];
|
|
20
|
+
const list = Array.isArray(incoming) ? incoming : [incoming];
|
|
21
|
+
|
|
22
|
+
for (const [index, entry] of list.entries()) {
|
|
23
|
+
if (!entry || typeof entry !== "object") {
|
|
24
|
+
rejected.push({ id: `#${index + 1}`, reason: "case must be an object" });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const id =
|
|
28
|
+
typeof entry.id === "string" && entry.id.trim()
|
|
29
|
+
? entry.id.trim()
|
|
30
|
+
: `case_${ids.size + index + 1}`;
|
|
31
|
+
if (ids.has(id)) {
|
|
32
|
+
rejected.push({ id, reason: "duplicate id" });
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (typeof entry.input !== "string" || entry.input.trim() === "") {
|
|
36
|
+
rejected.push({ id, reason: "input must be a non-empty string" });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const expectedTool = entry.expectedTool ?? null;
|
|
40
|
+
if (expectedTool !== null && !tools.has(expectedTool)) {
|
|
41
|
+
rejected.push({ id, reason: `unknown expectedTool "${expectedTool}"` });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const expectedArgs = entry.expectedArgs ?? {};
|
|
45
|
+
if (expectedTool === null && Object.keys(expectedArgs).length > 0) {
|
|
46
|
+
rejected.push({ id, reason: "expectedArgs requires expectedTool (omit both for negative cases)" });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (expectedTool !== null) {
|
|
50
|
+
const properties = tools.get(expectedTool).parameters?.properties ?? {};
|
|
51
|
+
for (const key of Object.keys(expectedArgs)) {
|
|
52
|
+
if (!(key in properties)) {
|
|
53
|
+
warnings.push(`${id}: expectedArgs.${key} 不在 ${expectedTool} 的 schema 中,请确认`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const testCase = {
|
|
59
|
+
id,
|
|
60
|
+
input: entry.input,
|
|
61
|
+
expectedTool,
|
|
62
|
+
expectedArgs,
|
|
63
|
+
...(entry.note ? { note: String(entry.note) } : {}),
|
|
64
|
+
source: typeof entry.source === "string" && entry.source ? entry.source : source,
|
|
65
|
+
reviewed: entry.reviewed === undefined ? reviewed : Boolean(entry.reviewed),
|
|
66
|
+
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : []
|
|
67
|
+
};
|
|
68
|
+
ids.add(id);
|
|
69
|
+
added.push(testCase);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!dryRun && added.length > 0) {
|
|
73
|
+
raw.cases = Array.isArray(raw.cases) ? raw.cases : [];
|
|
74
|
+
raw.cases.push(...added);
|
|
75
|
+
await writeFileAtomic(abs, `${JSON.stringify(raw, null, 2)}\n`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { path: abs, added, rejected, warnings, dryRun };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function computeCoverage(suite) {
|
|
82
|
+
const perTool = new Map(
|
|
83
|
+
suite.tools.map((tool) => [tool.name, { total: 0, withArgs: 0, reviewed: 0, unreviewed: 0 }])
|
|
84
|
+
);
|
|
85
|
+
let negative = 0;
|
|
86
|
+
let unreviewed = 0;
|
|
87
|
+
|
|
88
|
+
for (const testCase of suite.cases) {
|
|
89
|
+
const unreviewedCase = testCase.reviewed === false;
|
|
90
|
+
if (unreviewedCase) unreviewed += 1;
|
|
91
|
+
|
|
92
|
+
if (testCase.expectedTool === null) {
|
|
93
|
+
negative += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const bucket = perTool.get(testCase.expectedTool);
|
|
97
|
+
if (!bucket) continue;
|
|
98
|
+
bucket.total += 1;
|
|
99
|
+
if (Object.keys(testCase.expectedArgs ?? {}).length > 0) bucket.withArgs += 1;
|
|
100
|
+
if (unreviewedCase) bucket.unreviewed += 1;
|
|
101
|
+
else bucket.reviewed += 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const tools = [...perTool.entries()].map(([name, counts]) => ({
|
|
105
|
+
name,
|
|
106
|
+
...counts,
|
|
107
|
+
missing: Math.max(0, MIN_CASES_PER_TOOL - counts.total)
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const total = suite.cases.length;
|
|
111
|
+
const negativeRatio = total ? negative / total : 0;
|
|
112
|
+
const warnings = [];
|
|
113
|
+
|
|
114
|
+
for (const tool of tools) {
|
|
115
|
+
if (tool.total === 0) warnings.push(`${tool.name}: 0 条用例`);
|
|
116
|
+
else if (tool.missing > 0) warnings.push(`${tool.name}: ${tool.total}/${MIN_CASES_PER_TOOL} 条,缺 ${tool.missing} 条`);
|
|
117
|
+
if (tool.total > 0 && tool.withArgs === 0) warnings.push(`${tool.name}: 没有校验参数的用例`);
|
|
118
|
+
}
|
|
119
|
+
if (negativeRatio < MIN_NEGATIVE_RATIO) {
|
|
120
|
+
warnings.push(`负例占比 ${Math.round(negativeRatio * 100)}% < ${Math.round(MIN_NEGATIVE_RATIO * 100)}%`);
|
|
121
|
+
}
|
|
122
|
+
if (unreviewed > 0) warnings.push(`${unreviewed} 条未复核用例不计入正式分数`);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
total,
|
|
126
|
+
negative,
|
|
127
|
+
negativeRatio,
|
|
128
|
+
unreviewed,
|
|
129
|
+
tools,
|
|
130
|
+
pairs: suspectedPairs(suite.tools),
|
|
131
|
+
warnings
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatCoverage(report) {
|
|
136
|
+
const lines = [
|
|
137
|
+
`cases: ${report.total}(负例 ${report.negative},${Math.round(report.negativeRatio * 100)}%;未复核 ${report.unreviewed})`,
|
|
138
|
+
"",
|
|
139
|
+
"tool cases args reviewed",
|
|
140
|
+
"---------------------- ----- ---- --------"
|
|
141
|
+
];
|
|
142
|
+
for (const tool of report.tools) {
|
|
143
|
+
lines.push(
|
|
144
|
+
`${tool.name.padEnd(22)} ${String(tool.total).padStart(5)} ${String(tool.withArgs).padStart(4)} ${String(tool.reviewed).padStart(8)}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (report.pairs.length > 0) {
|
|
148
|
+
lines.push("", "suspected confusing pairs(建议各补 1 条歧义用例):");
|
|
149
|
+
for (const pair of report.pairs) {
|
|
150
|
+
lines.push(` ${pair.a} × ${pair.b} similarity ${pair.similarity.toFixed(2)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (report.warnings.length > 0) {
|
|
154
|
+
lines.push("", "warnings:");
|
|
155
|
+
for (const warning of report.warnings) lines.push(` - ${warning}`);
|
|
156
|
+
}
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function suspectedPairs(tools) {
|
|
161
|
+
const pairs = [];
|
|
162
|
+
for (let i = 0; i < tools.length; i += 1) {
|
|
163
|
+
for (let j = i + 1; j < tools.length; j += 1) {
|
|
164
|
+
const a = tokens(`${tools[i].name} ${tools[i].description}`);
|
|
165
|
+
const b = tokens(`${tools[j].name} ${tools[j].description}`);
|
|
166
|
+
const union = new Set([...a, ...b]);
|
|
167
|
+
if (union.size === 0) continue;
|
|
168
|
+
let shared = 0;
|
|
169
|
+
for (const token of union) if (a.has(token) && b.has(token)) shared += 1;
|
|
170
|
+
const similarity = shared / union.size;
|
|
171
|
+
if (similarity >= PAIR_SIMILARITY) {
|
|
172
|
+
pairs.push({ a: tools[i].name, b: tools[j].name, similarity });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return pairs.sort((x, y) => y.similarity - x.similarity);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function tokens(text) {
|
|
180
|
+
const set = new Set();
|
|
181
|
+
const lowered = String(text ?? "").toLowerCase();
|
|
182
|
+
for (const word of lowered.match(/[a-z][a-z0-9_]{1,}/g) ?? []) set.add(word);
|
|
183
|
+
for (const chunk of lowered.match(/[一-鿿]+/g) ?? []) {
|
|
184
|
+
if (chunk.length === 1) set.add(chunk);
|
|
185
|
+
for (let i = 0; i + 1 < chunk.length; i += 1) set.add(chunk.slice(i, i + 2));
|
|
186
|
+
}
|
|
187
|
+
return set;
|
|
188
|
+
}
|
package/lib/compare.js
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
export function compareScorecards(before, after) {
|
|
2
|
+
const fingerprintBefore = before.suite?.fingerprint ?? null;
|
|
3
|
+
const fingerprintAfter = after.suite?.fingerprint ?? null;
|
|
4
|
+
|
|
5
|
+
const toolChanges = diffEntries(fingerprintBefore?.tools, fingerprintAfter?.tools, "name");
|
|
6
|
+
const caseChanges = diffEntries(fingerprintBefore?.cases, fingerprintAfter?.cases, "id");
|
|
7
|
+
const executionBefore = before.suite?.execution?.hash ?? null;
|
|
8
|
+
const executionAfter = after.suite?.execution?.hash ?? null;
|
|
9
|
+
const executionChanged = executionBefore !== executionAfter;
|
|
10
|
+
const casesBefore = new Map((fingerprintBefore?.cases ?? []).map((entry) => [entry.id, entry.hash]));
|
|
11
|
+
const casesAfter = new Map((fingerprintAfter?.cases ?? []).map((entry) => [entry.id, entry.hash]));
|
|
12
|
+
const commonCaseIds = [...casesBefore.keys()].filter(
|
|
13
|
+
(id) => casesAfter.has(id) && casesAfter.get(id) === casesBefore.get(id)
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const modelsAfter = new Map((after.models ?? []).map((summary) => [summary.model, summary]));
|
|
17
|
+
const models = [];
|
|
18
|
+
|
|
19
|
+
for (const modelBefore of before.models ?? []) {
|
|
20
|
+
const modelAfter = modelsAfter.get(modelBefore.model);
|
|
21
|
+
if (!modelAfter) {
|
|
22
|
+
models.push({ model: modelBefore.model, comparable: false, reason: "模型不在新报告中" });
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const rowsBefore = new Map((modelBefore.rows ?? []).map((row) => [row.id, row]));
|
|
27
|
+
const rowsAfter = new Map((modelAfter.rows ?? []).map((row) => [row.id, row]));
|
|
28
|
+
const comparableIds = commonCaseIds.filter((id) => rowsBefore.has(id) && rowsAfter.has(id));
|
|
29
|
+
|
|
30
|
+
const regressions = [];
|
|
31
|
+
const fixes = [];
|
|
32
|
+
const argsDrops = [];
|
|
33
|
+
const confusionCounts = new Map();
|
|
34
|
+
const interferenceMap = new Map();
|
|
35
|
+
let reviewed = 0;
|
|
36
|
+
let selectionBefore = 0;
|
|
37
|
+
let selectionAfter = 0;
|
|
38
|
+
let passBefore = 0;
|
|
39
|
+
let passAfter = 0;
|
|
40
|
+
|
|
41
|
+
for (const id of comparableIds) {
|
|
42
|
+
const rowBefore = rowsBefore.get(id);
|
|
43
|
+
const rowAfter = rowsAfter.get(id);
|
|
44
|
+
if (rowBefore.reviewed === false || rowAfter.reviewed === false) continue;
|
|
45
|
+
reviewed += 1;
|
|
46
|
+
if (rowBefore.selectionCorrect) selectionBefore += 1;
|
|
47
|
+
if (rowAfter.selectionCorrect) selectionAfter += 1;
|
|
48
|
+
if (rowBefore.passed) passBefore += 1;
|
|
49
|
+
if (rowAfter.passed) passAfter += 1;
|
|
50
|
+
|
|
51
|
+
const confusionKey = `${rowAfter.expectedTool ?? "(none)"}→${rowAfter.actualTool ?? "(none)"}`;
|
|
52
|
+
confusionCounts.set(confusionKey, (confusionCounts.get(confusionKey) ?? 0) + 1);
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
rowBefore.selectionCorrect &&
|
|
56
|
+
!rowAfter.selectionCorrect &&
|
|
57
|
+
rowBefore.expectedTool &&
|
|
58
|
+
rowAfter.actualTool
|
|
59
|
+
) {
|
|
60
|
+
const interferenceKey = `${rowBefore.expectedTool}→${rowAfter.actualTool}`;
|
|
61
|
+
const entry = interferenceMap.get(interferenceKey) ?? {
|
|
62
|
+
expected: rowBefore.expectedTool,
|
|
63
|
+
stolenBy: rowAfter.actualTool,
|
|
64
|
+
cases: []
|
|
65
|
+
};
|
|
66
|
+
entry.cases.push(id);
|
|
67
|
+
interferenceMap.set(interferenceKey, entry);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (rowBefore.passed && !rowAfter.passed) {
|
|
71
|
+
regressions.push({
|
|
72
|
+
id,
|
|
73
|
+
before: rowBefore.actualTool ?? null,
|
|
74
|
+
after: rowAfter.actualTool ?? null,
|
|
75
|
+
args: rowAfter.args?.failures ?? []
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (!rowBefore.passed && rowAfter.passed) {
|
|
79
|
+
fixes.push({ id, before: rowBefore.actualTool ?? null, after: rowAfter.actualTool ?? null });
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
rowBefore.args?.total > 0 &&
|
|
83
|
+
rowAfter.args?.total > 0 &&
|
|
84
|
+
(rowAfter.args.matched ?? 0) < (rowBefore.args.matched ?? 0)
|
|
85
|
+
) {
|
|
86
|
+
argsDrops.push({
|
|
87
|
+
id,
|
|
88
|
+
before: rowBefore.args.matched,
|
|
89
|
+
after: rowAfter.args.matched,
|
|
90
|
+
total: rowBefore.args.total
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const confusion = [...confusionCounts.entries()].map(([key, count]) => {
|
|
96
|
+
const [expected, actual] = key.split("→");
|
|
97
|
+
return { expected, actual, count };
|
|
98
|
+
});
|
|
99
|
+
const interference = [...interferenceMap.values()].map((entry) => ({
|
|
100
|
+
...entry,
|
|
101
|
+
count: entry.cases.length,
|
|
102
|
+
stolenByIsNewTool: toolChanges.some(
|
|
103
|
+
(change) => change.change === "added" && change.name === entry.stolenBy
|
|
104
|
+
)
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
models.push({
|
|
108
|
+
model: modelBefore.model,
|
|
109
|
+
comparable: toolChanges.length === 0 && caseChanges.length === 0 && !executionChanged,
|
|
110
|
+
commonCases: comparableIds.length,
|
|
111
|
+
reviewed,
|
|
112
|
+
metrics: {
|
|
113
|
+
selection: { before: ratio(selectionBefore, reviewed), after: ratio(selectionAfter, reviewed) },
|
|
114
|
+
pass: { before: ratio(passBefore, reviewed), after: ratio(passAfter, reviewed) }
|
|
115
|
+
},
|
|
116
|
+
regressions,
|
|
117
|
+
fixes,
|
|
118
|
+
argsDrops,
|
|
119
|
+
confusion,
|
|
120
|
+
interference
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
beforeHash: fingerprintBefore?.hash ?? null,
|
|
126
|
+
afterHash: fingerprintAfter?.hash ?? null,
|
|
127
|
+
toolChanges,
|
|
128
|
+
caseChanges,
|
|
129
|
+
executionChanged,
|
|
130
|
+
executionBefore,
|
|
131
|
+
executionAfter,
|
|
132
|
+
models
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function formatCompare(report) {
|
|
137
|
+
const lines = [];
|
|
138
|
+
if (!report.beforeHash || !report.afterHash) {
|
|
139
|
+
lines.push("warn: 至少一份报告没有 fingerprint(旧版本生成),无法确认定义是否相同");
|
|
140
|
+
}
|
|
141
|
+
lines.push(`tool changes: ${report.toolChanges.length} case changes: ${report.caseChanges.length}`);
|
|
142
|
+
if (report.executionChanged) {
|
|
143
|
+
lines.push("execution: 执行入口代码已变更,执行结果不可直接比较");
|
|
144
|
+
}
|
|
145
|
+
for (const change of report.toolChanges) {
|
|
146
|
+
lines.push(` tool ${change.change}: ${change.name}`);
|
|
147
|
+
}
|
|
148
|
+
for (const change of report.caseChanges) {
|
|
149
|
+
lines.push(` case ${change.change}: ${change.name}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const model of report.models) {
|
|
153
|
+
lines.push("");
|
|
154
|
+
if (model.reviewed === undefined) {
|
|
155
|
+
lines.push(`[${model.model}] ${model.reason}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
lines.push(`[${model.model}] ${model.comparable ? "可比较" : "不可直接比较(工具定义或用例已变更)"}`);
|
|
159
|
+
lines.push(` 共同且未变更用例: ${model.commonCases}(已复核 ${model.reviewed})`);
|
|
160
|
+
lines.push(` 选择准确率: ${percent(model.metrics.selection.before)} → ${percent(model.metrics.selection.after)}`);
|
|
161
|
+
lines.push(` 通过率: ${percent(model.metrics.pass.before)} → ${percent(model.metrics.pass.after)}`);
|
|
162
|
+
if (model.interference.length > 0) {
|
|
163
|
+
lines.push(" 工具间干扰(原本选对,现在被别的工具抢走):");
|
|
164
|
+
for (const item of model.interference) {
|
|
165
|
+
const tag = item.stolenByIsNewTool ? "(来自本次新增的工具)" : "";
|
|
166
|
+
lines.push(` - ${item.expected} → ${item.stolenBy}: ${item.count} 条${tag} ${item.cases.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const offDiagonal = (model.confusion ?? []).filter((entry) => entry.expected !== entry.actual);
|
|
170
|
+
if (offDiagonal.length > 0) {
|
|
171
|
+
lines.push(" 误选分布:");
|
|
172
|
+
for (const entry of offDiagonal) {
|
|
173
|
+
lines.push(` - 期望 ${entry.expected},实际 ${entry.actual}: ${entry.count}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (model.regressions.length > 0) {
|
|
177
|
+
lines.push(" 回归:");
|
|
178
|
+
for (const item of model.regressions) {
|
|
179
|
+
const args = item.args.length > 0 ? `(参数缺 ${item.args.join(", ")})` : "";
|
|
180
|
+
lines.push(` - ${item.id}: ${item.before ?? "(none)"} → ${item.after ?? "(none)"}${args}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (model.fixes.length > 0) {
|
|
184
|
+
lines.push(" 修复:");
|
|
185
|
+
for (const item of model.fixes) {
|
|
186
|
+
lines.push(` - ${item.id}: ${item.before ?? "(none)"} → ${item.after ?? "(none)"}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (model.argsDrops.length > 0) {
|
|
190
|
+
lines.push(" 参数匹配下降:");
|
|
191
|
+
for (const item of model.argsDrops) {
|
|
192
|
+
lines.push(` - ${item.id}: ${item.before}/${item.total} → ${item.after}/${item.total}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return lines.join("\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function diffEntries(beforeList, afterList, key) {
|
|
201
|
+
const before = new Map((beforeList ?? []).map((entry) => [entry[key], entry.hash]));
|
|
202
|
+
const after = new Map((afterList ?? []).map((entry) => [entry[key], entry.hash]));
|
|
203
|
+
const changes = [];
|
|
204
|
+
for (const [name, hash] of before) {
|
|
205
|
+
if (!after.has(name)) changes.push({ name, change: "removed", before: hash });
|
|
206
|
+
else if (after.get(name) !== hash) changes.push({ name, change: "changed", before: hash, after: after.get(name) });
|
|
207
|
+
}
|
|
208
|
+
for (const [name, hash] of after) {
|
|
209
|
+
if (!before.has(name)) changes.push({ name, change: "added", after: hash });
|
|
210
|
+
}
|
|
211
|
+
return changes;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ratio(numerator, denominator) {
|
|
215
|
+
return denominator ? numerator / denominator : null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function percent(value) {
|
|
219
|
+
return value === null ? "n/a" : `${Math.round(value * 100)}%`;
|
|
220
|
+
}
|
package/lib/eval-exec.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { executeTool } from "./exec.js";
|
|
3
|
+
|
|
4
|
+
export async function runExecutionChecks({ suite, cases, summaries, options }) {
|
|
5
|
+
const config = suite.execution ?? {};
|
|
6
|
+
const entry = options.execEntry ?? config.entry;
|
|
7
|
+
if (!entry) {
|
|
8
|
+
throw new Error("--execute 需要 --entry,或套件中的 execution.entry");
|
|
9
|
+
}
|
|
10
|
+
const entryPath = resolve(entry);
|
|
11
|
+
const runtime = config.runtime ?? options.execRuntime ?? "node";
|
|
12
|
+
const engine = runtime === "python" ? "python-audit" : "node-permission";
|
|
13
|
+
const python = config.python ?? options.execPython ?? "python";
|
|
14
|
+
const exportName = config.export ?? options.execExport ?? "run";
|
|
15
|
+
const timeoutMs = config.timeoutMs ?? options.execTimeoutMs ?? 10_000;
|
|
16
|
+
const allowRead = config.allowRead ?? [];
|
|
17
|
+
const allowWrite = config.allowWrite ?? [];
|
|
18
|
+
const allowNet = config.allowNet ?? [];
|
|
19
|
+
|
|
20
|
+
const tools = new Map(suite.tools.map((tool) => [tool.name, tool]));
|
|
21
|
+
|
|
22
|
+
for (const summary of summaries) {
|
|
23
|
+
const rows = new Map(summary.rows.map((row) => [row.id, row]));
|
|
24
|
+
const results = [];
|
|
25
|
+
let skipped = 0;
|
|
26
|
+
let blocked = 0;
|
|
27
|
+
|
|
28
|
+
for (const testCase of cases) {
|
|
29
|
+
const row = rows.get(testCase.id);
|
|
30
|
+
if (!row || row.reviewed === false || !testCase.toolInput || testCase.expectedTool === null) {
|
|
31
|
+
skipped += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!row.selectionCorrect) {
|
|
35
|
+
skipped += 1;
|
|
36
|
+
results.push({ id: testCase.id, skipped: true, reason: "selection failed" });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sideEffects = tools.get(testCase.expectedTool)?.sideEffects ?? null;
|
|
41
|
+
const safe = sideEffects === "none" || sideEffects === "read";
|
|
42
|
+
if (!safe && !options.allowSideEffects) {
|
|
43
|
+
blocked += 1;
|
|
44
|
+
skipped += 1;
|
|
45
|
+
results.push({
|
|
46
|
+
id: testCase.id,
|
|
47
|
+
skipped: true,
|
|
48
|
+
reason: `sideEffects=${sideEffects ?? "unknown"} requires --allow-side-effects`
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const execution = await executeTool({
|
|
54
|
+
entry: entryPath,
|
|
55
|
+
runtime,
|
|
56
|
+
python,
|
|
57
|
+
exportName,
|
|
58
|
+
timeoutMs,
|
|
59
|
+
warmup: 0,
|
|
60
|
+
inputJson: JSON.stringify({ tool: testCase.expectedTool, arguments: testCase.toolInput }),
|
|
61
|
+
allowRead,
|
|
62
|
+
allowWrite,
|
|
63
|
+
allowNet
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const checks = evaluateExpect(testCase.expect, execution.result);
|
|
67
|
+
const entryResult = {
|
|
68
|
+
id: testCase.id,
|
|
69
|
+
passed: checks.passed,
|
|
70
|
+
failures: checks.failures,
|
|
71
|
+
sideEffects,
|
|
72
|
+
durationMs: execution.result.metrics?.durationMs ?? null,
|
|
73
|
+
peakRssMB: execution.result.metrics?.peakRssMB ?? null,
|
|
74
|
+
exitCode: execution.code
|
|
75
|
+
};
|
|
76
|
+
results.push(entryResult);
|
|
77
|
+
row.exec = entryResult;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const executed = results.filter((item) => !item.skipped);
|
|
81
|
+
const durations = executed.map((item) => item.durationMs).filter((value) => typeof value === "number");
|
|
82
|
+
const rss = executed.map((item) => item.peakRssMB).filter((value) => typeof value === "number");
|
|
83
|
+
const passed = executed.filter((item) => item.passed).length;
|
|
84
|
+
summary.exec = {
|
|
85
|
+
engine,
|
|
86
|
+
total: results.length,
|
|
87
|
+
executed: executed.length,
|
|
88
|
+
skipped,
|
|
89
|
+
blocked,
|
|
90
|
+
passed,
|
|
91
|
+
passRate: executed.length > 0 ? passed / executed.length : null,
|
|
92
|
+
p50DurationMs: percentile(durations, 0.5),
|
|
93
|
+
maxPeakRssMB: rss.length > 0 ? Math.max(...rss) : null,
|
|
94
|
+
results
|
|
95
|
+
};
|
|
96
|
+
summary.execPassRate = summary.exec.passRate;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function evaluateExpect(expect, execution) {
|
|
101
|
+
const failures = [];
|
|
102
|
+
const expected = expect ?? { ok: true };
|
|
103
|
+
const metrics = execution.metrics ?? {};
|
|
104
|
+
|
|
105
|
+
if (expected.ok !== undefined && execution.ok !== expected.ok) {
|
|
106
|
+
failures.push(`ok: expected ${expected.ok}, got ${execution.ok}`);
|
|
107
|
+
}
|
|
108
|
+
if (expected.errorType && execution.error?.name !== expected.errorType) {
|
|
109
|
+
failures.push(`errorType: expected ${expected.errorType}, got ${execution.error?.name ?? "none"}`);
|
|
110
|
+
}
|
|
111
|
+
if (expected.contains !== undefined) {
|
|
112
|
+
const text = JSON.stringify(execution.result ?? "");
|
|
113
|
+
for (const needle of [].concat(expected.contains)) {
|
|
114
|
+
if (!text.includes(String(needle))) failures.push(`contains: 缺少 ${needle}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (expected.equals !== undefined && JSON.stringify(execution.result) !== JSON.stringify(expected.equals)) {
|
|
118
|
+
failures.push("equals: 返回结果不一致");
|
|
119
|
+
}
|
|
120
|
+
if (typeof expected.maxDurationMs === "number") {
|
|
121
|
+
const value = metrics.durationMs;
|
|
122
|
+
if (typeof value !== "number" || value > expected.maxDurationMs) {
|
|
123
|
+
failures.push(`maxDurationMs: ${value ?? "n/a"} > ${expected.maxDurationMs}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (typeof expected.maxPeakRssMB === "number") {
|
|
127
|
+
const value = metrics.peakRssMB;
|
|
128
|
+
if (typeof value !== "number" || value > expected.maxPeakRssMB) {
|
|
129
|
+
failures.push(`maxPeakRssMB: ${value ?? "n/a"} > ${expected.maxPeakRssMB}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { passed: failures.length === 0, failures };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function percentile(values, ratio) {
|
|
136
|
+
if (values.length === 0) return null;
|
|
137
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
138
|
+
const index = Math.max(0, Math.min(sorted.length - 1, Math.ceil(ratio * sorted.length) - 1));
|
|
139
|
+
return Math.round(sorted[index]);
|
|
140
|
+
}
|