modelfunction-cli 0.4.4 → 0.5.1
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 +36 -6
- package/SCHEMA.md +112 -4
- package/lib/audit.js +149 -0
- package/lib/budget.js +30 -0
- package/lib/credentials.js +121 -0
- package/lib/fsutil.js +5 -3
- package/lib/mcp-probe.js +59 -9
- package/lib/mcp-server.js +29 -9
- package/lib/sandbox-check.js +3 -1
- package/lib/scan.js +20 -2
- package/lib/score.js +14 -7
- package/lib/usage.js +341 -0
- package/mf.js +441 -59
- package/package.json +1 -1
package/lib/mcp-server.js
CHANGED
|
@@ -8,8 +8,9 @@ import { z } from "zod";
|
|
|
8
8
|
import { loadSuite } from "./suite.js";
|
|
9
9
|
import { scanProject, addTools, buildSuiteDraft, redactReport } from "./scan.js";
|
|
10
10
|
import { writeFileAtomic } from "./fsutil.js";
|
|
11
|
-
import { probeStdioServer } from "./mcp-probe.js";
|
|
11
|
+
import { probeStdioServer, probeHttpServer } from "./mcp-probe.js";
|
|
12
12
|
import { addCases, computeCoverage } from "./cases.js";
|
|
13
|
+
import { auditSuite } from "./audit.js";
|
|
13
14
|
|
|
14
15
|
const MF_PATH = fileURLToPath(new URL("../mf.js", import.meta.url));
|
|
15
16
|
const PACKAGE_JSON = new URL("../package.json", import.meta.url);
|
|
@@ -32,7 +33,7 @@ export async function startMcpServer() {
|
|
|
32
33
|
{
|
|
33
34
|
title: "Scan tools",
|
|
34
35
|
description:
|
|
35
|
-
"扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio MCP server 拉取 tools/list
|
|
36
|
+
"扫描仓库里的 function.json / OpenAPI / MCP 配置 / SKILL.md / 代码内定义。probe=true 时连接 stdio 或 streamable-HTTP MCP server 拉取 tools/list(HTTP headers 只用于连接,不写入报告)。不执行工具。默认只读;传 writeSuite 时在该路径写出套件草稿(cases 为空,需补用例并复核;来源 ≥2 时草稿带按来源分组的 profiles,并列出不参与评测的 skills)。",
|
|
36
37
|
inputSchema: {
|
|
37
38
|
root: z.string().optional().describe("仓库目录,默认进程当前目录"),
|
|
38
39
|
probe: z.boolean().optional().describe("是否连接 MCP server 拉取工具清单"),
|
|
@@ -44,11 +45,10 @@ export async function startMcpServer() {
|
|
|
44
45
|
const report = await scanProject(root ?? process.cwd());
|
|
45
46
|
if (probe) {
|
|
46
47
|
for (const mcpServer of [...report.mcpServers]) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const result = await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
|
|
48
|
+
const result =
|
|
49
|
+
mcpServer.transport === "stdio"
|
|
50
|
+
? await probeStdioServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 })
|
|
51
|
+
: await probeHttpServer(mcpServer, { timeoutMs: timeoutMs ?? 5000 });
|
|
52
52
|
if (!result.ok) {
|
|
53
53
|
report.probes.push({ server: mcpServer.name, ok: false, error: result.error });
|
|
54
54
|
continue;
|
|
@@ -109,6 +109,22 @@ export async function startMcpServer() {
|
|
|
109
109
|
async ({ suite }) => json(computeCoverage(await loadSuite(suitePath(suite))))
|
|
110
110
|
);
|
|
111
111
|
|
|
112
|
+
server.registerTool(
|
|
113
|
+
"audit",
|
|
114
|
+
{
|
|
115
|
+
title: "Audit tool set",
|
|
116
|
+
description:
|
|
117
|
+
"一次静态审计整个工具集:lint 发现、混淆族与边界建议、用例覆盖、挂载检查。不调用模型、不执行工具、无网络。返回 JSON。",
|
|
118
|
+
inputSchema: { suite: z.string().optional().describe("套件路径,默认 ./mf.suite.json") }
|
|
119
|
+
},
|
|
120
|
+
async ({ suite }) =>
|
|
121
|
+
json(
|
|
122
|
+
auditSuite(
|
|
123
|
+
await loadSuite(suitePath(suite), { allowEmptyCases: true, allowEmptyTools: true })
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
);
|
|
127
|
+
|
|
112
128
|
server.registerTool(
|
|
113
129
|
"add_cases",
|
|
114
130
|
{
|
|
@@ -154,16 +170,20 @@ export async function startMcpServer() {
|
|
|
154
170
|
.max(10)
|
|
155
171
|
.optional()
|
|
156
172
|
.describe("每条用例重复次数,取多数结果;建议正式基线用 3"),
|
|
157
|
-
failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2")
|
|
173
|
+
failUnder: z.number().optional().describe("选择准确率阈值,低于则退出码为 2"),
|
|
174
|
+
maxCost: z.number().positive().optional().describe("累计估算成本上限(CNY),超限退出 2 且不返回部分评分卡"),
|
|
175
|
+
progress: z.boolean().optional().describe("把逐用例进度写到 stderr(stdout 仍为纯 JSON)")
|
|
158
176
|
}
|
|
159
177
|
},
|
|
160
|
-
async ({ suite, models, cases, profile, repeat, failUnder }) => {
|
|
178
|
+
async ({ suite, models, cases, profile, repeat, failUnder, maxCost, progress }) => {
|
|
161
179
|
const args = ["eval", "--json", "--suite", suitePath(suite)];
|
|
162
180
|
if (models) args.push("--models", models);
|
|
163
181
|
if (cases) args.push("--case", cases);
|
|
164
182
|
if (profile) args.push("--profile", profile);
|
|
165
183
|
if (repeat !== undefined) args.push("--repeat", String(repeat));
|
|
166
184
|
if (failUnder !== undefined) args.push("--fail-under", String(failUnder));
|
|
185
|
+
if (maxCost !== undefined) args.push("--max-cost", String(maxCost));
|
|
186
|
+
if (progress) args.push("--progress");
|
|
167
187
|
|
|
168
188
|
const result = await runCli(args);
|
|
169
189
|
let scorecard = null;
|
package/lib/sandbox-check.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { permissionFlag } from "./node-permission.js";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// Shared CI runners can be slow to start a process; a 4s probe that times out
|
|
5
|
+
// under load turns into a false "engine unavailable" and flakes the suite.
|
|
6
|
+
function run(command, args, timeout = 10_000) {
|
|
5
7
|
const result = spawnSync(command, args, { encoding: "utf8", timeout, windowsHide: true });
|
|
6
8
|
return {
|
|
7
9
|
ok: result.status === 0,
|
package/lib/scan.js
CHANGED
|
@@ -337,9 +337,11 @@ export function addTools(
|
|
|
337
337
|
export function redactReport(report) {
|
|
338
338
|
return {
|
|
339
339
|
...report,
|
|
340
|
-
mcpServers: report.mcpServers.map(({ env, ...rest }) => ({
|
|
340
|
+
mcpServers: report.mcpServers.map(({ env, headers, ...rest }) => ({
|
|
341
341
|
...rest,
|
|
342
|
-
|
|
342
|
+
url: rest.url ? sanitizeUrl(rest.url) : null,
|
|
343
|
+
envKeys: env ? Object.keys(env) : [],
|
|
344
|
+
headerKeys: headers ? Object.keys(headers).sort() : []
|
|
343
345
|
}))
|
|
344
346
|
};
|
|
345
347
|
}
|
|
@@ -526,10 +528,26 @@ function mcpServersFrom(config) {
|
|
|
526
528
|
command: value.command ?? null,
|
|
527
529
|
args: Array.isArray(value.args) ? value.args : [],
|
|
528
530
|
env: value.env && typeof value.env === "object" ? value.env : null,
|
|
531
|
+
headers: value.headers && typeof value.headers === "object" ? value.headers : null,
|
|
529
532
|
url: value.url ?? null
|
|
530
533
|
}));
|
|
531
534
|
}
|
|
532
535
|
|
|
536
|
+
// Remote MCP configs often carry tokens in the query string; keep the report
|
|
537
|
+
// safe to share while the live server object still probes the full URL.
|
|
538
|
+
function sanitizeUrl(raw) {
|
|
539
|
+
try {
|
|
540
|
+
const url = new URL(raw);
|
|
541
|
+
url.username = "";
|
|
542
|
+
url.password = "";
|
|
543
|
+
url.search = "";
|
|
544
|
+
url.hash = "";
|
|
545
|
+
return url.toString();
|
|
546
|
+
} catch {
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
533
551
|
function toolsFromOpenApi(spec) {
|
|
534
552
|
const tools = [];
|
|
535
553
|
const paths = spec.paths ?? {};
|
package/lib/score.js
CHANGED
|
@@ -76,6 +76,19 @@ export function pickRepresentative(runs) {
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export function costCnyOf(usage, pricing) {
|
|
80
|
+
if (!pricing) return 0;
|
|
81
|
+
const input = usage?.input ?? 0;
|
|
82
|
+
const cached = Math.min(input, usage?.cached ?? 0);
|
|
83
|
+
const output = usage?.output ?? 0;
|
|
84
|
+
return (
|
|
85
|
+
((input - cached) * (pricing.input ?? 0) +
|
|
86
|
+
cached * (pricing.cachedInput ?? pricing.input ?? 0) +
|
|
87
|
+
output * (pricing.output ?? 0)) /
|
|
88
|
+
1e6
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
79
92
|
export function scoreModel({ model, cases, results, pricing }) {
|
|
80
93
|
const rows = cases.map((testCase, index) => {
|
|
81
94
|
const result = results[index];
|
|
@@ -115,13 +128,7 @@ export function scoreModel({ model, cases, results, pricing }) {
|
|
|
115
128
|
inputTokens,
|
|
116
129
|
rows.reduce((sum, row) => sum + (row.usage.cached ?? 0), 0)
|
|
117
130
|
);
|
|
118
|
-
const
|
|
119
|
-
const costCny = pricing
|
|
120
|
-
? (uncachedTokens * (pricing.input ?? 0) +
|
|
121
|
-
cachedTokens * (pricing.cachedInput ?? pricing.input ?? 0) +
|
|
122
|
-
outputTokens * (pricing.output ?? 0)) /
|
|
123
|
-
1e6
|
|
124
|
-
: 0;
|
|
131
|
+
const costCny = rows.reduce((sum, row) => sum + costCnyOf(row.usage, pricing), 0);
|
|
125
132
|
const reviewedTotal = reviewedRows.length;
|
|
126
133
|
|
|
127
134
|
return {
|
package/lib/usage.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { appendFile, mkdir, readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { configDir } from "./credentials.js";
|
|
5
|
+
|
|
6
|
+
// Events are redacted by construction: only counts, ids and ratios leave the
|
|
7
|
+
// process. Never add free-form text (inputs, descriptions, paths, errors).
|
|
8
|
+
const OPT_OUT_ENV = "MF_NO_USAGE";
|
|
9
|
+
const CI_ENV_KEYS = [
|
|
10
|
+
"GITHUB_ACTIONS",
|
|
11
|
+
"GITLAB_CI",
|
|
12
|
+
"CIRCLECI",
|
|
13
|
+
"TRAVIS",
|
|
14
|
+
"BUILDKITE",
|
|
15
|
+
"TF_BUILD",
|
|
16
|
+
"JENKINS_URL",
|
|
17
|
+
"CNB_BUILD_ID"
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
let pendingDetails = null;
|
|
21
|
+
|
|
22
|
+
export function usageDir() {
|
|
23
|
+
return join(configDir(), "usage");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function usageFile(date = new Date()) {
|
|
27
|
+
const month = date.toISOString().slice(0, 7);
|
|
28
|
+
return join(usageDir(), `${month}.jsonl`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function feedbackDir() {
|
|
32
|
+
return join(configDir(), "feedback");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function defaultFeedbackPath(date = new Date()) {
|
|
36
|
+
const stamp = date.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
37
|
+
return join(feedbackDir(), `mf-feedback-${stamp}.json`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function noteUsage(details) {
|
|
41
|
+
pendingDetails = { ...(pendingDetails ?? {}), ...sanitizeDetails(details) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function recordRun({ command, exitCode = 0, durationMs = 0, version }) {
|
|
45
|
+
if (process.env[OPT_OUT_ENV] === "1") {
|
|
46
|
+
pendingDetails = null;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const record = buildRecord({ command, exitCode, durationMs, version });
|
|
50
|
+
try {
|
|
51
|
+
await mkdir(usageDir(), { recursive: true, mode: 0o700 });
|
|
52
|
+
await appendFile(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
53
|
+
} catch {
|
|
54
|
+
// Usage logging must never fail a command.
|
|
55
|
+
} finally {
|
|
56
|
+
pendingDetails = null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The process "exit" hook is synchronous, so the CLI uses this variant.
|
|
61
|
+
export function recordRunSync({ command, exitCode = 0, durationMs = 0, version }) {
|
|
62
|
+
if (process.env[OPT_OUT_ENV] === "1") {
|
|
63
|
+
pendingDetails = null;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const record = buildRecord({ command, exitCode, durationMs, version });
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(usageDir(), { recursive: true, mode: 0o700 });
|
|
69
|
+
appendFileSync(usageFile(), `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
} finally {
|
|
73
|
+
pendingDetails = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildRecord({ command, exitCode, durationMs, version }) {
|
|
78
|
+
return {
|
|
79
|
+
schema: 1,
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
command: typeof command === "string" ? command.slice(0, 32) : "unknown",
|
|
82
|
+
exitCode: Number.isInteger(exitCode) ? exitCode : 1,
|
|
83
|
+
durationMs: Math.max(0, Math.round(Number(durationMs) || 0)),
|
|
84
|
+
mf: typeof version === "string" ? version : "unknown",
|
|
85
|
+
platform: process.platform,
|
|
86
|
+
arch: process.arch,
|
|
87
|
+
node: process.versions.node,
|
|
88
|
+
ci: detectCi(),
|
|
89
|
+
...(pendingDetails ?? {})
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function detectCi() {
|
|
94
|
+
if (process.env.CI === "true" || process.env.CI === "1") return true;
|
|
95
|
+
return CI_ENV_KEYS.some((key) => Boolean(process.env[key]));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readUsageEvents() {
|
|
99
|
+
let files = [];
|
|
100
|
+
try {
|
|
101
|
+
files = (await readdir(usageDir())).filter((name) => /^\d{4}-\d{2}\.jsonl$/.test(name)).sort();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (error.code === "ENOENT") return { events: [], files: [], skipped: 0 };
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const events = [];
|
|
107
|
+
let skipped = 0;
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const text = await readFile(join(usageDir(), file), "utf8");
|
|
110
|
+
for (const line of text.split(/\r?\n/)) {
|
|
111
|
+
if (!line.trim()) continue;
|
|
112
|
+
try {
|
|
113
|
+
const parsed = JSON.parse(line);
|
|
114
|
+
if (parsed && typeof parsed === "object" && typeof parsed.ts === "string") events.push(parsed);
|
|
115
|
+
else skipped += 1;
|
|
116
|
+
} catch {
|
|
117
|
+
skipped += 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
events.sort((a, b) => a.ts.localeCompare(b.ts));
|
|
122
|
+
return { events, files, skipped };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function summarizeUsage(events) {
|
|
126
|
+
const summary = {
|
|
127
|
+
runs: events.length,
|
|
128
|
+
firstRun: events[0]?.ts ?? null,
|
|
129
|
+
lastRun: events.at(-1)?.ts ?? null,
|
|
130
|
+
activeDays: new Set(events.map((event) => event.ts.slice(0, 10))).size,
|
|
131
|
+
ciRuns: 0,
|
|
132
|
+
localRuns: 0,
|
|
133
|
+
exitCodes: {},
|
|
134
|
+
commands: {},
|
|
135
|
+
platforms: {},
|
|
136
|
+
nodeVersions: {},
|
|
137
|
+
mfVersions: {},
|
|
138
|
+
models: {},
|
|
139
|
+
eval: { runs: 0, executed: 0, meanSelection: null, meanPass: null, meanArgs: null, unstableCases: 0 },
|
|
140
|
+
lint: { runs: 0, errors: 0, warns: 0, infos: 0, rules: {} },
|
|
141
|
+
check: { runs: 0, verdicts: {} },
|
|
142
|
+
scan: { runs: 0, tools: 0, skills: 0 }
|
|
143
|
+
};
|
|
144
|
+
const selection = [];
|
|
145
|
+
const pass = [];
|
|
146
|
+
const args = [];
|
|
147
|
+
|
|
148
|
+
for (const event of events) {
|
|
149
|
+
if (event.ci) summary.ciRuns += 1;
|
|
150
|
+
else summary.localRuns += 1;
|
|
151
|
+
bump(summary.exitCodes, String(event.exitCode ?? 0));
|
|
152
|
+
bump(summary.commands, event.command ?? "unknown");
|
|
153
|
+
if (event.platform) bump(summary.platforms, event.platform);
|
|
154
|
+
if (event.node) bump(summary.nodeVersions, event.node);
|
|
155
|
+
if (event.mf) bump(summary.mfVersions, event.mf);
|
|
156
|
+
for (const model of event.models ?? []) bump(summary.models, model);
|
|
157
|
+
|
|
158
|
+
if (event.command === "eval") {
|
|
159
|
+
summary.eval.runs += 1;
|
|
160
|
+
if (event.execEngine) summary.eval.executed += 1;
|
|
161
|
+
if (event.unstableCases) summary.eval.unstableCases += event.unstableCases;
|
|
162
|
+
if (typeof event.selectionAccuracy === "number") selection.push(event.selectionAccuracy);
|
|
163
|
+
if (typeof event.passRate === "number") pass.push(event.passRate);
|
|
164
|
+
if (typeof event.argAccuracy === "number") args.push(event.argAccuracy);
|
|
165
|
+
}
|
|
166
|
+
if (event.command === "lint") {
|
|
167
|
+
summary.lint.runs += 1;
|
|
168
|
+
summary.lint.errors += event.lintErrors ?? 0;
|
|
169
|
+
summary.lint.warns += event.lintWarns ?? 0;
|
|
170
|
+
summary.lint.infos += event.lintInfos ?? 0;
|
|
171
|
+
for (const [rule, count] of Object.entries(event.lintRules ?? {})) {
|
|
172
|
+
summary.lint.rules[rule] = (summary.lint.rules[rule] ?? 0) + count;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (event.command === "check") {
|
|
176
|
+
summary.check.runs += 1;
|
|
177
|
+
bump(summary.check.verdicts, event.checkVerdict ?? "unknown");
|
|
178
|
+
}
|
|
179
|
+
if (event.command === "scan") {
|
|
180
|
+
summary.scan.runs += 1;
|
|
181
|
+
summary.scan.tools += event.scanTools ?? 0;
|
|
182
|
+
summary.scan.skills += event.scanSkills ?? 0;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
summary.eval.meanSelection = mean(selection);
|
|
187
|
+
summary.eval.meanPass = mean(pass);
|
|
188
|
+
summary.eval.meanArgs = mean(args);
|
|
189
|
+
return summary;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function buildFeedbackBundle({ events, version, generatedAt = new Date() }) {
|
|
193
|
+
return {
|
|
194
|
+
schema: 1,
|
|
195
|
+
kind: "modelfunction-feedback",
|
|
196
|
+
generatedAt: generatedAt.toISOString(),
|
|
197
|
+
mf: {
|
|
198
|
+
version,
|
|
199
|
+
node: process.versions.node,
|
|
200
|
+
platform: process.platform,
|
|
201
|
+
arch: process.arch
|
|
202
|
+
},
|
|
203
|
+
usage: summarizeUsage(events),
|
|
204
|
+
privacy:
|
|
205
|
+
"仅包含命令名、次数、模型 id、计数与比率。不含用例原文、工具描述、源码、文件路径或密钥。",
|
|
206
|
+
request:
|
|
207
|
+
"我们想了解:哪些命令被真实使用、在哪些平台上跑、评测通过率大概什么水平、lint/check 主要命中哪些规则。用于决定下一步做什么,不需要你的项目细节。"
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function formatStats(summary, { events = 0, files = 0, skipped = 0, path = usageDir() } = {}) {
|
|
212
|
+
if (summary.runs === 0) {
|
|
213
|
+
return [
|
|
214
|
+
`local usage: ${path}`,
|
|
215
|
+
"还没有本地记录。运行任意 mf 命令后会自动记录(仅计数,不上传)。",
|
|
216
|
+
"opt out: set MF_NO_USAGE=1"
|
|
217
|
+
].join("\n");
|
|
218
|
+
}
|
|
219
|
+
const lines = [
|
|
220
|
+
`local usage: ${path} (${events} events, ${files} file(s)${skipped > 0 ? `, ${skipped} unreadable line(s)` : ""})`,
|
|
221
|
+
`runs: ${summary.runs} (local ${summary.localRuns}, ci ${summary.ciRuns}) first: ${summary.firstRun} last: ${summary.lastRun} active days: ${summary.activeDays}`,
|
|
222
|
+
`exit codes: ${pairs(summary.exitCodes)}`,
|
|
223
|
+
`commands: ${pairs(summary.commands)}`
|
|
224
|
+
];
|
|
225
|
+
if (Object.keys(summary.models).length > 0) lines.push(`models: ${pairs(summary.models)}`);
|
|
226
|
+
if (summary.eval.runs > 0) {
|
|
227
|
+
const parts = [`${summary.eval.runs} run(s)`];
|
|
228
|
+
if (summary.eval.meanSelection !== null) parts.push(`select ${percent(summary.eval.meanSelection)}`);
|
|
229
|
+
if (summary.eval.meanPass !== null) parts.push(`pass ${percent(summary.eval.meanPass)}`);
|
|
230
|
+
if (summary.eval.executed > 0) parts.push(`${summary.eval.executed} with execution`);
|
|
231
|
+
if (summary.eval.unstableCases > 0) parts.push(`${summary.eval.unstableCases} unstable case(s)`);
|
|
232
|
+
lines.push(`eval: ${parts.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
if (summary.lint.runs > 0) {
|
|
235
|
+
const top = Object.entries(summary.lint.rules)
|
|
236
|
+
.sort((a, b) => b[1] - a[1])
|
|
237
|
+
.slice(0, 5)
|
|
238
|
+
.map(([rule, count]) => `${rule} ${count}`);
|
|
239
|
+
lines.push(
|
|
240
|
+
`lint: ${summary.lint.runs} run(s), error ${summary.lint.errors} / warn ${summary.lint.warns} / info ${summary.lint.infos}` +
|
|
241
|
+
(top.length > 0 ? `; top: ${top.join(", ")}` : "")
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (summary.check.runs > 0) lines.push(`check: ${summary.check.runs} run(s), ${pairs(summary.check.verdicts)}`);
|
|
245
|
+
if (summary.scan.runs > 0) lines.push(`scan: ${summary.scan.runs} run(s), ${summary.scan.tools} tools, ${summary.scan.skills} skills`);
|
|
246
|
+
lines.push(`versions: mf ${keys(summary.mfVersions)} node ${keys(summary.nodeVersions)} os ${keys(summary.platforms)}`);
|
|
247
|
+
lines.push(`no network, no telemetry. opt out: set MF_NO_USAGE=1. clear: delete ${path}`);
|
|
248
|
+
return lines.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bump(target, key) {
|
|
252
|
+
target[key] = (target[key] ?? 0) + 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function mean(values) {
|
|
256
|
+
if (values.length === 0) return null;
|
|
257
|
+
return Math.round((values.reduce((sum, value) => sum + value, 0) / values.length) * 10000) / 10000;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function percent(value) {
|
|
261
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function keys(counts) {
|
|
265
|
+
return Object.keys(counts).sort().slice(0, 5).join(", ") || "-";
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function pairs(counts) {
|
|
269
|
+
return (
|
|
270
|
+
Object.entries(counts)
|
|
271
|
+
.sort((a, b) => b[1] - a[1])
|
|
272
|
+
.map(([key, value]) => `${key} ${value}`)
|
|
273
|
+
.join(", ") || "-"
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const INT_FIELDS = new Set([
|
|
278
|
+
"caseCount",
|
|
279
|
+
"toolCount",
|
|
280
|
+
"reviewedCount",
|
|
281
|
+
"profileGroups",
|
|
282
|
+
"repeat",
|
|
283
|
+
"unstableCases",
|
|
284
|
+
"execExecuted",
|
|
285
|
+
"execPassed",
|
|
286
|
+
"execBlocked",
|
|
287
|
+
"execSkipped",
|
|
288
|
+
"scanTools",
|
|
289
|
+
"scanSkills",
|
|
290
|
+
"scanMcpServers",
|
|
291
|
+
"scanProbes",
|
|
292
|
+
"lintErrors",
|
|
293
|
+
"lintWarns",
|
|
294
|
+
"lintInfos",
|
|
295
|
+
"checkFindings",
|
|
296
|
+
"clusters"
|
|
297
|
+
]);
|
|
298
|
+
const RATIO_FIELDS = new Set(["selectionAccuracy", "argAccuracy", "passRate"]);
|
|
299
|
+
const BOOL_FIELDS = new Set(["failedThreshold", "regressions"]);
|
|
300
|
+
const STRING_FIELDS = new Set(["execEngine", "checkVerdict"]);
|
|
301
|
+
const STRING_ARRAY_FIELDS = new Set(["models"]);
|
|
302
|
+
const COUNT_OBJECT_FIELDS = new Set(["lintRules"]);
|
|
303
|
+
|
|
304
|
+
export function sanitizeDetails(details) {
|
|
305
|
+
if (!details || typeof details !== "object") return {};
|
|
306
|
+
const out = {};
|
|
307
|
+
for (const [key, value] of Object.entries(details)) {
|
|
308
|
+
if (INT_FIELDS.has(key)) {
|
|
309
|
+
if (Number.isInteger(value) && value >= 0) out[key] = value;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (RATIO_FIELDS.has(key)) {
|
|
313
|
+
if (typeof value === "number" && Number.isFinite(value)) out[key] = Math.round(value * 10000) / 10000;
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
if (BOOL_FIELDS.has(key)) {
|
|
317
|
+
if (typeof value === "boolean") out[key] = value;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (STRING_FIELDS.has(key)) {
|
|
321
|
+
if (typeof value === "string" && /^[A-Za-z0-9_-]{1,32}$/.test(value)) out[key] = value;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
if (STRING_ARRAY_FIELDS.has(key)) {
|
|
325
|
+
if (Array.isArray(value)) {
|
|
326
|
+
out[key] = value.filter((item) => typeof item === "string" && item.length <= 64).slice(0, 20);
|
|
327
|
+
}
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
if (COUNT_OBJECT_FIELDS.has(key)) {
|
|
331
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
332
|
+
out[key] = Object.fromEntries(
|
|
333
|
+
Object.entries(value)
|
|
334
|
+
.filter(([rule, count]) => /^[a-z0-9-]{1,40}$/.test(rule) && Number.isInteger(count) && count >= 0)
|
|
335
|
+
.slice(0, 40)
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return out;
|
|
341
|
+
}
|