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/mf.js
CHANGED
|
@@ -3,18 +3,30 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { loadSuite, toModelTools } from "./lib/suite.js";
|
|
6
|
-
import { scoreModel, pct } from "./lib/score.js";
|
|
6
|
+
import { scoreModel, pickRepresentative, pct } from "./lib/score.js";
|
|
7
7
|
import { formatTable, formatCaseLines, formatMarkdown } from "./lib/report.js";
|
|
8
8
|
import { scanProject, buildSuiteDraft, formatScanSummary, addTools, redactReport } from "./lib/scan.js";
|
|
9
9
|
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 { suiteFingerprint, hashText } from "./lib/fingerprint.js";
|
|
13
|
+
import { suiteFingerprint, hashText, hashOf } from "./lib/fingerprint.js";
|
|
14
14
|
import { runExecutionChecks } from "./lib/eval-exec.js";
|
|
15
15
|
import { checkSandbox, formatSandboxReport } from "./lib/sandbox-check.js";
|
|
16
16
|
import { compareScorecards, formatCompare } from "./lib/compare.js";
|
|
17
17
|
import { analyzeAblation, formatAblation } from "./lib/ablate.js";
|
|
18
|
+
import { clusterTools, formatClusters } from "./lib/cluster.js";
|
|
19
|
+
import {
|
|
20
|
+
normalizeProfiles,
|
|
21
|
+
selectProfiles,
|
|
22
|
+
selectProfileCases,
|
|
23
|
+
profileWarnings,
|
|
24
|
+
analyzeMount,
|
|
25
|
+
modelTotals,
|
|
26
|
+
profileCaseCoverage,
|
|
27
|
+
formatProfileReport,
|
|
28
|
+
formatProfileMarkdown
|
|
29
|
+
} from "./lib/profile.js";
|
|
18
30
|
import { runExec } from "./lib/exec.js";
|
|
19
31
|
import { writeFileAtomic } from "./lib/fsutil.js";
|
|
20
32
|
import { createMockAdapter } from "./lib/adapters/mock.js";
|
|
@@ -38,9 +50,10 @@ Usage:
|
|
|
38
50
|
mf scan [options]
|
|
39
51
|
mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
|
|
40
52
|
mf coverage [--suite <file>] [--json]
|
|
41
|
-
mf lint [--suite <file>] [--json]
|
|
53
|
+
mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
|
|
42
54
|
mf compare <before.json> <after.json> [--json]
|
|
43
|
-
mf ablate --tool <name> [--suite <file>] [--models <ids>] [--json]
|
|
55
|
+
mf ablate --tool <name> [--suite <file>] [--profile <group>] [--models <ids>] [--json]
|
|
56
|
+
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
44
57
|
mf exec --entry <file> (--input <json> | --input-json <json>) [--runtime node|python] [--python <cmd>] [--export run] [--allow-read <paths>] [--allow-write <paths>] [--allow-net <hosts>] [--timeout <ms>] [--warmup <n>] [--json]
|
|
45
58
|
mf sandbox check [--json] # probe available isolation engines on this machine
|
|
46
59
|
mf mcp # start stdio MCP server for coding agents
|
|
@@ -50,6 +63,8 @@ Eval options:
|
|
|
50
63
|
--models <ids> comma-separated ids from models.json (default: models with API keys set)
|
|
51
64
|
--models-file <path> model registry (default: built-in registry)
|
|
52
65
|
--case <ids> run only these case ids (comma-separated, repeatable)
|
|
66
|
+
--profile <names> score each mounted group separately ("all" or names from suite.profiles)
|
|
67
|
+
--repeat <n> run each case n times and score the majority; reports unstable cases (default 1)
|
|
53
68
|
--show-cases print per-case results
|
|
54
69
|
--json print JSON instead of the table
|
|
55
70
|
--out <file> write scorecard (.md => markdown, otherwise JSON)
|
|
@@ -81,23 +96,27 @@ Examples:
|
|
|
81
96
|
`;
|
|
82
97
|
|
|
83
98
|
export async function runEval(options, io = console) {
|
|
99
|
+
if (options.profiles && options.execute) {
|
|
100
|
+
throw new Error("--profile 暂不支持与 --execute 组合");
|
|
101
|
+
}
|
|
84
102
|
const suite = await loadSuite(options.suite);
|
|
85
103
|
const cases = selectCases(suite.cases, options.cases);
|
|
86
104
|
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
|
|
87
105
|
const tools = toModelTools(suite.tools);
|
|
88
106
|
|
|
89
|
-
const
|
|
107
|
+
const runs = [];
|
|
90
108
|
for (const adapter of adapters) {
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
);
|
|
109
|
+
const evaluated = await evaluate(adapter, tools, cases, options.repeat ?? 1);
|
|
110
|
+
const summary = scoreModel({
|
|
111
|
+
model: adapter.id,
|
|
112
|
+
cases,
|
|
113
|
+
results: evaluated.results,
|
|
114
|
+
pricing: registry[adapter.id]?.pricing ?? null
|
|
115
|
+
});
|
|
116
|
+
if (evaluated.stability) summary.stability = evaluated.stability;
|
|
117
|
+
runs.push({ adapter, results: evaluated.results, summary });
|
|
100
118
|
}
|
|
119
|
+
const summaries = runs.map((run) => run.summary);
|
|
101
120
|
|
|
102
121
|
let executionInfo = null;
|
|
103
122
|
if (options.execute) {
|
|
@@ -124,18 +143,30 @@ export async function runEval(options, io = console) {
|
|
|
124
143
|
};
|
|
125
144
|
}
|
|
126
145
|
|
|
146
|
+
const profileReport = options.profiles
|
|
147
|
+
? await evaluateProfiles({
|
|
148
|
+
suite,
|
|
149
|
+
cases,
|
|
150
|
+
runs,
|
|
151
|
+
registry,
|
|
152
|
+
selection: options.profiles,
|
|
153
|
+
repeat: options.repeat ?? 1
|
|
154
|
+
})
|
|
155
|
+
: null;
|
|
156
|
+
|
|
127
157
|
if (options.out) {
|
|
128
158
|
const target = resolve(options.out);
|
|
129
159
|
const markdown = extname(target).toLowerCase() === ".md";
|
|
130
160
|
const payload = markdown
|
|
131
|
-
? formatMarkdown(suite, cases, summaries)
|
|
132
|
-
|
|
161
|
+
? formatMarkdown(suite, cases, summaries) +
|
|
162
|
+
(profileReport ? `\n\n${formatProfileMarkdown(profileReport)}` : "")
|
|
163
|
+
: JSON.stringify(serialize(suite, cases, summaries, executionInfo, profileReport), null, 2);
|
|
133
164
|
await writeFileAtomic(target, payload);
|
|
134
165
|
io.log(`wrote ${target}`);
|
|
135
166
|
}
|
|
136
167
|
|
|
137
168
|
if (options.json) {
|
|
138
|
-
io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo), null, 2));
|
|
169
|
+
io.log(JSON.stringify(serialize(suite, cases, summaries, executionInfo, profileReport), null, 2));
|
|
139
170
|
} else {
|
|
140
171
|
io.log(`suite: ${suite.name} — ${cases.length} cases, ${suite.tools.length} tools`);
|
|
141
172
|
io.log(`file: ${suite.path}`);
|
|
@@ -147,6 +178,14 @@ export async function runEval(options, io = console) {
|
|
|
147
178
|
io.log("");
|
|
148
179
|
}
|
|
149
180
|
io.log(formatTable([summary]));
|
|
181
|
+
if (summary.stability) {
|
|
182
|
+
const unstable = summary.stability.unstable;
|
|
183
|
+
const list = unstable.slice(0, 8).map((item) => item.id).join(", ");
|
|
184
|
+
io.log(
|
|
185
|
+
`stability: ${summary.stability.runs} 次/用例,${unstable.length} 条结果不稳定` +
|
|
186
|
+
(unstable.length > 0 ? `(${list}${unstable.length > 8 ? "…" : ""})` : "")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
150
189
|
if (summary.unreviewedTotal > 0) {
|
|
151
190
|
io.log(`note: ${summary.unreviewedTotal} unreviewed case(s) excluded from select/args/pass`);
|
|
152
191
|
}
|
|
@@ -170,10 +209,18 @@ export async function runEval(options, io = console) {
|
|
|
170
209
|
io.log("combined");
|
|
171
210
|
io.log(formatTable(summaries));
|
|
172
211
|
}
|
|
212
|
+
if (profileReport) {
|
|
213
|
+
io.log("");
|
|
214
|
+
io.log(formatProfileReport(profileReport, { showCases: options.showCases }));
|
|
215
|
+
}
|
|
173
216
|
if (summaries.some((summary) => summary.rows.some((row) => row.usage?.estimated))) {
|
|
174
217
|
io.log("");
|
|
175
218
|
io.log("note: tokens marked with ~ are estimated (mock adapter); pricing in models.json is indicative.");
|
|
176
219
|
}
|
|
220
|
+
if (summaries.some((summary) => summary.model === "mock")) {
|
|
221
|
+
io.log("");
|
|
222
|
+
io.log("note: mock 是规则路由器,只用于验证流程;它在你自己的工具上得低分不代表真实模型表现,请用 --models <id> 测真实模型。");
|
|
223
|
+
}
|
|
177
224
|
}
|
|
178
225
|
|
|
179
226
|
let regressions = [];
|
|
@@ -210,6 +257,17 @@ export async function runEval(options, io = console) {
|
|
|
210
257
|
);
|
|
211
258
|
return 2;
|
|
212
259
|
}
|
|
260
|
+
if (profileReport && options.failUnder !== null) {
|
|
261
|
+
const below = profileReport.models.flatMap((model) =>
|
|
262
|
+
model.profiles
|
|
263
|
+
.filter((entry) => entry.reviewed > 0 && entry.mounted.selectionAccuracy < options.failUnder)
|
|
264
|
+
.map((entry) => `${model.model}/${entry.profile} (${pct(entry.mounted.selectionAccuracy)})`)
|
|
265
|
+
);
|
|
266
|
+
if (below.length > 0) {
|
|
267
|
+
io.error(`profile below threshold ${pct(options.failUnder)}: ${below.join(", ")}`);
|
|
268
|
+
return 2;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
213
271
|
if (regressions.length > 0) return 2;
|
|
214
272
|
return 0;
|
|
215
273
|
}
|
|
@@ -220,25 +278,40 @@ export async function runAblate(options, io = console) {
|
|
|
220
278
|
const ablated = suite.tools.find((tool) => tool.name === options.tool);
|
|
221
279
|
if (!ablated) throw new Error(`unknown tool "${options.tool}"`);
|
|
222
280
|
|
|
223
|
-
const
|
|
281
|
+
const scope = options.profiles && options.profiles.length > 0 ? ablationScope(suite, options.profiles) : null;
|
|
282
|
+
if (scope && !scope.tools.some((tool) => tool.name === ablated.name)) {
|
|
283
|
+
throw new Error(`tool "${ablated.name}" 不在组 "${scope.name}" 里`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const selectedCases = selectCases(suite.cases, options.cases);
|
|
287
|
+
const baseTools = scope ? scope.tools : suite.tools;
|
|
288
|
+
const baseCases = scope
|
|
289
|
+
? selectProfileCases(selectedCases, new Set(scope.tools.map((tool) => tool.name)))
|
|
290
|
+
: selectedCases;
|
|
224
291
|
const reducedCases = baseCases.filter((testCase) => testCase.expectedTool !== ablated.name);
|
|
225
292
|
const droppedCases = baseCases.length - reducedCases.length;
|
|
226
293
|
const { registry, adapters } = await resolveModels(options.models, options.modelsFile, io);
|
|
227
294
|
|
|
228
|
-
const fullTools = toModelTools(
|
|
229
|
-
const reducedTools = toModelTools(
|
|
295
|
+
const fullTools = toModelTools(baseTools);
|
|
296
|
+
const reducedTools = toModelTools(baseTools.filter((tool) => tool.name !== ablated.name));
|
|
230
297
|
const models = [];
|
|
231
298
|
|
|
232
299
|
for (const adapter of adapters) {
|
|
233
300
|
const pricing = registry[adapter.id]?.pricing ?? null;
|
|
234
|
-
const fullResults = await evaluate(adapter, fullTools, baseCases);
|
|
301
|
+
const fullResults = (await evaluate(adapter, fullTools, baseCases)).results;
|
|
235
302
|
const full = scoreModel({ model: adapter.id, cases: baseCases, results: fullResults, pricing });
|
|
236
|
-
const reducedResults = await evaluate(adapter, reducedTools, reducedCases);
|
|
303
|
+
const reducedResults = (await evaluate(adapter, reducedTools, reducedCases)).results;
|
|
237
304
|
const reduced = scoreModel({ model: adapter.id, cases: reducedCases, results: reducedResults, pricing });
|
|
238
305
|
models.push(analyzeAblation(adapter.id, full, reduced));
|
|
239
306
|
}
|
|
240
307
|
|
|
241
|
-
const payload = {
|
|
308
|
+
const payload = {
|
|
309
|
+
tool: ablated.name,
|
|
310
|
+
suite: suite.name,
|
|
311
|
+
droppedCases,
|
|
312
|
+
models,
|
|
313
|
+
...(scope ? { profile: { name: scope.name, tools: baseTools.length } } : {})
|
|
314
|
+
};
|
|
242
315
|
io.log(options.json ? JSON.stringify(payload, null, 2) : formatAblation(payload));
|
|
243
316
|
return 0;
|
|
244
317
|
}
|
|
@@ -271,7 +344,8 @@ export async function runScan(options, io = console) {
|
|
|
271
344
|
source: server.source,
|
|
272
345
|
origin: "mcp-probe",
|
|
273
346
|
confidence: "high",
|
|
274
|
-
note: `server ${server.name}
|
|
347
|
+
note: `server ${server.name}`,
|
|
348
|
+
meta: { provider: server.name }
|
|
275
349
|
});
|
|
276
350
|
report.probes.push({ server: server.name, ok: true, toolCount: added.length, tools: added });
|
|
277
351
|
}
|
|
@@ -333,7 +407,9 @@ export async function runCaseAdd(options, io = console) {
|
|
|
333
407
|
}
|
|
334
408
|
|
|
335
409
|
export async function runCoverage(options, io = console) {
|
|
336
|
-
|
|
410
|
+
// A scan draft has no cases yet; "what is still missing" is exactly the view
|
|
411
|
+
// a user needs right after scanning.
|
|
412
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), { allowEmptyCases: true });
|
|
337
413
|
const report = computeCoverage(suite);
|
|
338
414
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
339
415
|
return 0;
|
|
@@ -346,7 +422,7 @@ export async function runSandboxCheck(options, io = console) {
|
|
|
346
422
|
}
|
|
347
423
|
|
|
348
424
|
export async function runLint(options, io = console) {
|
|
349
|
-
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE));
|
|
425
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), { allowEmptyCases: true });
|
|
350
426
|
const report = lintSuite(suite);
|
|
351
427
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
352
428
|
return report.counts.error > 0 ? 1 : 0;
|
|
@@ -416,29 +492,44 @@ function autoSelect(registry) {
|
|
|
416
492
|
.map(([id]) => id);
|
|
417
493
|
}
|
|
418
494
|
|
|
419
|
-
async function evaluate(adapter, tools, cases) {
|
|
495
|
+
async function evaluate(adapter, tools, cases, repeat = 1) {
|
|
420
496
|
const results = [];
|
|
497
|
+
const unstable = [];
|
|
421
498
|
for (const testCase of cases) {
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
];
|
|
426
|
-
try {
|
|
427
|
-
results.push(await adapter.call({ input: testCase.input, messages, tools }));
|
|
428
|
-
} catch (error) {
|
|
429
|
-
results.push({
|
|
430
|
-
ok: false,
|
|
431
|
-
error: String(error?.message ?? error),
|
|
432
|
-
toolCalls: [],
|
|
433
|
-
usage: { input: 0, output: 0 },
|
|
434
|
-
latencyMs: 0
|
|
435
|
-
});
|
|
499
|
+
const runs = [];
|
|
500
|
+
for (let attempt = 0; attempt < repeat; attempt += 1) {
|
|
501
|
+
runs.push(await callOnce(adapter, tools, testCase));
|
|
436
502
|
}
|
|
503
|
+
if (repeat === 1) {
|
|
504
|
+
results.push(runs[0]);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const picked = pickRepresentative(runs);
|
|
508
|
+
results.push(picked.result);
|
|
509
|
+
if (picked.unstable) unstable.push({ id: testCase.id, outcomes: picked.outcomes });
|
|
510
|
+
}
|
|
511
|
+
return { results, stability: repeat > 1 ? { runs: repeat, unstable } : null };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function callOnce(adapter, tools, testCase) {
|
|
515
|
+
const messages = [
|
|
516
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
517
|
+
{ role: "user", content: testCase.input }
|
|
518
|
+
];
|
|
519
|
+
try {
|
|
520
|
+
return await adapter.call({ input: testCase.input, messages, tools });
|
|
521
|
+
} catch (error) {
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
error: String(error?.message ?? error),
|
|
525
|
+
toolCalls: [],
|
|
526
|
+
usage: { input: 0, output: 0 },
|
|
527
|
+
latencyMs: 0
|
|
528
|
+
};
|
|
437
529
|
}
|
|
438
|
-
return results;
|
|
439
530
|
}
|
|
440
531
|
|
|
441
|
-
function serialize(suite, cases, summaries, executionInfo = null) {
|
|
532
|
+
function serialize(suite, cases, summaries, executionInfo = null, profileReport = null) {
|
|
442
533
|
return {
|
|
443
534
|
suite: {
|
|
444
535
|
name: suite.name,
|
|
@@ -448,10 +539,105 @@ function serialize(suite, cases, summaries, executionInfo = null) {
|
|
|
448
539
|
fingerprint: suiteFingerprint(suite, cases),
|
|
449
540
|
...(executionInfo ? { execution: executionInfo } : {})
|
|
450
541
|
},
|
|
451
|
-
models: summaries
|
|
542
|
+
models: summaries,
|
|
543
|
+
...(profileReport ? { profiles: profileReport } : {})
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function ablationScope(suite, names) {
|
|
548
|
+
if (names.length !== 1) throw new Error("mf ablate --profile 一次只能指定一个组");
|
|
549
|
+
const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
|
|
550
|
+
if (!profiles) {
|
|
551
|
+
throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
|
|
552
|
+
}
|
|
553
|
+
const [profile] = selectProfiles(profiles, names);
|
|
554
|
+
const mounted = new Set(profile.tools);
|
|
555
|
+
return { name: profile.name, tools: suite.tools.filter((tool) => mounted.has(tool.name)) };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function evaluateProfiles({ suite, cases, runs, registry, selection, repeat = 1 }) {
|
|
559
|
+
const profiles = normalizeProfiles(suite.profiles, suite.tools.map((tool) => tool.name));
|
|
560
|
+
if (!profiles) {
|
|
561
|
+
throw new Error(`suite ${suite.name} 没有 profiles;先运行 mf cluster --out <suite> 生成分组`);
|
|
562
|
+
}
|
|
563
|
+
const selected = selectProfiles(profiles, selection);
|
|
564
|
+
const indexById = new Map(cases.map((testCase, index) => [testCase.id, index]));
|
|
565
|
+
const warnings = profileWarnings(selected, suite);
|
|
566
|
+
|
|
567
|
+
const groups = [];
|
|
568
|
+
const caseSets = selected.map((profile) => {
|
|
569
|
+
const profileCases = selectProfileCases(cases, new Set(profile.tools));
|
|
570
|
+
if (profileCases.length === 0) warnings.push(`profile "${profile.name}" 没有可评分的用例,已跳过`);
|
|
571
|
+
groups.push({ name: profile.name, tools: profile.tools.length, cases: profileCases.length });
|
|
572
|
+
return profileCases;
|
|
573
|
+
});
|
|
574
|
+
for (const row of profileCaseCoverage(selected, cases)) {
|
|
575
|
+
if (row.cases === 0) {
|
|
576
|
+
warnings.push(`组 ${row.name} 只有负例,mounted 分数只反映"不该调用",不反映工具选择`);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const models = [];
|
|
581
|
+
for (const run of runs) {
|
|
582
|
+
const pricing = registry[run.adapter.id]?.pricing ?? null;
|
|
583
|
+
const entries = [];
|
|
584
|
+
for (const [index, profile] of selected.entries()) {
|
|
585
|
+
const profileCases = caseSets[index];
|
|
586
|
+
if (profileCases.length === 0) continue;
|
|
587
|
+
const mountedTools = suite.tools.filter((tool) => profile.tools.includes(tool.name));
|
|
588
|
+
const mountedEval = await evaluate(run.adapter, toModelTools(mountedTools), profileCases, repeat);
|
|
589
|
+
const mounted = scoreModel({
|
|
590
|
+
model: run.adapter.id,
|
|
591
|
+
cases: profileCases,
|
|
592
|
+
results: mountedEval.results,
|
|
593
|
+
pricing
|
|
594
|
+
});
|
|
595
|
+
const allInOne = scoreModel({
|
|
596
|
+
model: run.adapter.id,
|
|
597
|
+
cases: profileCases,
|
|
598
|
+
results: profileCases.map((testCase) => run.results[indexById.get(testCase.id)]),
|
|
599
|
+
pricing
|
|
600
|
+
});
|
|
601
|
+
const entry = analyzeMount(profile, mounted, allInOne);
|
|
602
|
+
if (mountedEval.stability) entry.stability = mountedEval.stability;
|
|
603
|
+
entries.push(entry);
|
|
604
|
+
}
|
|
605
|
+
if (entries.length > 0 && entries.every((entry) => entry.reviewed === 0)) {
|
|
606
|
+
warnings.push("所有用例都未复核,分组分数不可用;把认可的用例 reviewed 改为 true 后再看");
|
|
607
|
+
}
|
|
608
|
+
models.push({ model: run.adapter.id, profiles: entries, totals: modelTotals(entries) });
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return {
|
|
612
|
+
hash: hashOf(
|
|
613
|
+
selected.map((profile) => [profile.name, [...profile.tools].sort()]).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
614
|
+
),
|
|
615
|
+
groups,
|
|
616
|
+
warnings,
|
|
617
|
+
models
|
|
452
618
|
};
|
|
453
619
|
}
|
|
454
620
|
|
|
621
|
+
export async function runCluster(options, io = console) {
|
|
622
|
+
const suite = await loadSuite(options.suite, { allowEmptyCases: true });
|
|
623
|
+
const result = clusterTools(suite.tools, { threshold: options.threshold, maxSize: options.maxSize });
|
|
624
|
+
if (options.out) {
|
|
625
|
+
const target = resolve(options.out);
|
|
626
|
+
const profiles = Object.fromEntries(result.clusters.map((cluster) => [cluster.label, cluster.tools]));
|
|
627
|
+
// Keep the suite file as the author wrote it (refs, execution, extra keys)
|
|
628
|
+
// and only add profiles.
|
|
629
|
+
const raw = JSON.parse(await readFile(suite.path, "utf8"));
|
|
630
|
+
await writeFileAtomic(target, `${JSON.stringify({ ...raw, profiles }, null, 2)}\n`);
|
|
631
|
+
if (raw.profiles && Object.keys(raw.profiles).length > 0) {
|
|
632
|
+
io.log(`note: 覆盖原有 ${Object.keys(raw.profiles).length} 个分组,原 profiles 不保留`);
|
|
633
|
+
}
|
|
634
|
+
io.log(`profiles written: ${target}`);
|
|
635
|
+
}
|
|
636
|
+
if (options.json) io.log(JSON.stringify(result, null, 2));
|
|
637
|
+
else io.log(formatClusters(result));
|
|
638
|
+
return 0;
|
|
639
|
+
}
|
|
640
|
+
|
|
455
641
|
function parseArgs(argv) {
|
|
456
642
|
const options = {
|
|
457
643
|
command: argv[0],
|
|
@@ -459,6 +645,8 @@ function parseArgs(argv) {
|
|
|
459
645
|
models: null,
|
|
460
646
|
modelsFile: DEFAULT_MODELS,
|
|
461
647
|
cases: null,
|
|
648
|
+
profiles: null,
|
|
649
|
+
repeat: 1,
|
|
462
650
|
showCases: false,
|
|
463
651
|
json: false,
|
|
464
652
|
out: null,
|
|
@@ -474,6 +662,8 @@ function parseArgs(argv) {
|
|
|
474
662
|
reviewed: false,
|
|
475
663
|
dryRun: false,
|
|
476
664
|
tool: null,
|
|
665
|
+
threshold: 0.5,
|
|
666
|
+
maxSize: 10,
|
|
477
667
|
baseline: null,
|
|
478
668
|
failOnRegression: false,
|
|
479
669
|
execute: false,
|
|
@@ -505,6 +695,13 @@ function parseArgs(argv) {
|
|
|
505
695
|
case "--models": options.models = splitList(value()); break;
|
|
506
696
|
case "--models-file": options.modelsFile = resolve(value()); break;
|
|
507
697
|
case "--case": options.cases = (options.cases ?? []).concat(splitList(value())); break;
|
|
698
|
+
case "--profile": options.profiles = splitList(value()); break;
|
|
699
|
+
case "--repeat": {
|
|
700
|
+
const raw = Number(value());
|
|
701
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > 10) throw new Error("--repeat expects an integer between 1 and 10");
|
|
702
|
+
options.repeat = raw;
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
508
705
|
case "--show-cases": options.showCases = true; break;
|
|
509
706
|
case "--json": options.json = true; break;
|
|
510
707
|
case "--out": options.out = value(); break;
|
|
@@ -524,6 +721,18 @@ function parseArgs(argv) {
|
|
|
524
721
|
case "--reviewed": options.reviewed = true; break;
|
|
525
722
|
case "--dry-run": options.dryRun = true; break;
|
|
526
723
|
case "--tool": options.tool = value(); break;
|
|
724
|
+
case "--threshold": {
|
|
725
|
+
const raw = Number(value());
|
|
726
|
+
if (!Number.isFinite(raw) || raw <= 0 || raw > 1) throw new Error("--threshold expects a ratio between 0 and 1");
|
|
727
|
+
options.threshold = raw;
|
|
728
|
+
break;
|
|
729
|
+
}
|
|
730
|
+
case "--max-size": {
|
|
731
|
+
const raw = Number(value());
|
|
732
|
+
if (!Number.isInteger(raw) || raw <= 0) throw new Error("--max-size expects a positive integer");
|
|
733
|
+
options.maxSize = raw;
|
|
734
|
+
break;
|
|
735
|
+
}
|
|
527
736
|
case "--baseline": options.baseline = resolve(value()); break;
|
|
528
737
|
case "--fail-on-regression": options.failOnRegression = true; break;
|
|
529
738
|
case "--entry": options.entry = value(); break;
|
|
@@ -605,7 +814,7 @@ if (isMain) {
|
|
|
605
814
|
}
|
|
606
815
|
} else if (argv.length === 0 || argv[0] === "help" || argv[0] === "-h" || argv[0] === "--help") {
|
|
607
816
|
process.stdout.write(USAGE);
|
|
608
|
-
} else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "exec"].includes(argv[0])) {
|
|
817
|
+
} else if (!["eval", "scan", "case", "coverage", "lint", "ablate", "cluster", "exec"].includes(argv[0])) {
|
|
609
818
|
process.stderr.write(`unknown command "${argv[0]}"\n\n${USAGE}`);
|
|
610
819
|
process.exit(1);
|
|
611
820
|
} else if (argv[0] === "case" && argv[1] !== "add") {
|
|
@@ -615,6 +824,10 @@ if (isMain) {
|
|
|
615
824
|
try {
|
|
616
825
|
const args = argv[0] === "case" ? [argv[0], ...argv.slice(2)] : argv;
|
|
617
826
|
const options = parseArgs(args);
|
|
827
|
+
if (options.help) {
|
|
828
|
+
process.stdout.write(USAGE);
|
|
829
|
+
process.exit(0);
|
|
830
|
+
}
|
|
618
831
|
const code =
|
|
619
832
|
argv[0] === "scan"
|
|
620
833
|
? await runScan(options)
|
|
@@ -626,7 +839,9 @@ if (isMain) {
|
|
|
626
839
|
? await runLint(options)
|
|
627
840
|
: argv[0] === "ablate"
|
|
628
841
|
? await runAblate(options)
|
|
629
|
-
: argv[0] === "
|
|
842
|
+
: argv[0] === "cluster"
|
|
843
|
+
? await runCluster(options)
|
|
844
|
+
: argv[0] === "exec"
|
|
630
845
|
? await runExec(options)
|
|
631
846
|
: await runEval(options);
|
|
632
847
|
process.exit(code);
|