modelfunction-cli 0.2.0 → 0.4.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 +5 -5
- package/lib/cases.js +32 -13
- package/lib/cluster.js +57 -2
- package/lib/js-tools.js +954 -0
- package/lib/lint.js +0 -0
- package/lib/mcp-server.js +1 -0
- package/lib/scan.js +80 -14
- package/lib/suite.js +20 -3
- package/mf.js +12 -5
- package/package.json +1 -1
package/lib/lint.js
CHANGED
|
Binary file
|
package/lib/mcp-server.js
CHANGED
|
@@ -57,6 +57,7 @@ export async function startMcpServer() {
|
|
|
57
57
|
source: mcpServer.source,
|
|
58
58
|
origin: "mcp-probe",
|
|
59
59
|
confidence: "high",
|
|
60
|
+
replaceExisting: true,
|
|
60
61
|
note: `server ${mcpServer.name}`
|
|
61
62
|
});
|
|
62
63
|
report.probes.push({ server: mcpServer.name, ok: true, toolCount: added.length, tools: added });
|
package/lib/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
2
|
import { basename, extname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { extractJsTools } from "./js-tools.js";
|
|
3
4
|
|
|
4
5
|
const SKIP_DIRS = new Set([
|
|
5
6
|
"node_modules",
|
|
@@ -19,7 +20,10 @@ const SKIP_DIRS = new Set([
|
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
const MAX_DEPTH = 6;
|
|
22
|
-
|
|
23
|
+
// 5k was too small: a real agent monorepo (openclaw) has 16k files after the
|
|
24
|
+
// skip list, so the walk stopped a third of the way in and reported 26 of its
|
|
25
|
+
// 53 skills. Silent partial results are worse than a slow scan.
|
|
26
|
+
const MAX_FILES = 25000;
|
|
23
27
|
const MAX_FILE_BYTES = 200_000;
|
|
24
28
|
|
|
25
29
|
const FUNCTION_SPEC_NAMES = new Set([
|
|
@@ -81,7 +85,8 @@ const JS_FRAMEWORKS = {
|
|
|
81
85
|
export async function scanProject(root = ".") {
|
|
82
86
|
const absRoot = resolve(root);
|
|
83
87
|
const files = [];
|
|
84
|
-
|
|
88
|
+
const state = { truncated: false };
|
|
89
|
+
await walk(absRoot, 0, files, state);
|
|
85
90
|
|
|
86
91
|
const report = {
|
|
87
92
|
root: absRoot,
|
|
@@ -97,7 +102,16 @@ export async function scanProject(root = ".") {
|
|
|
97
102
|
warnings: []
|
|
98
103
|
};
|
|
99
104
|
|
|
105
|
+
if (state.truncated) {
|
|
106
|
+
report.warnings.push(`扫描在 ${MAX_FILES} 个文件处截断,结果可能不完整;可把 --root 指向子目录`);
|
|
107
|
+
}
|
|
108
|
+
|
|
100
109
|
const seenTools = new Set();
|
|
110
|
+
// Tool names are frequently constants defined in a sibling module
|
|
111
|
+
// (`name: CONFIG_TOOL_NAME`). Files are visited in path order, so the
|
|
112
|
+
// constant may not be known yet; such files are retried after the walk.
|
|
113
|
+
const constIndex = new Map();
|
|
114
|
+
const retryFiles = new Set();
|
|
101
115
|
const dependencies = new Set();
|
|
102
116
|
const frameworks = new Set();
|
|
103
117
|
|
|
@@ -224,6 +238,23 @@ export async function scanProject(root = ".") {
|
|
|
224
238
|
});
|
|
225
239
|
}
|
|
226
240
|
}
|
|
241
|
+
if (!name.endsWith(".py") && !TEST_PATH.test(rel)) {
|
|
242
|
+
for (const match of text.matchAll(/(?:export\s+)?const\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(['"])([^'"\n]{1,200})\2/g)) {
|
|
243
|
+
if (!constIndex.has(match[1])) constIndex.set(match[1], match[3]);
|
|
244
|
+
}
|
|
245
|
+
const jsTools = extractJsTools(text, {
|
|
246
|
+
externals: constIndex,
|
|
247
|
+
onUnresolvedName: () => retryFiles.add(file)
|
|
248
|
+
});
|
|
249
|
+
if (jsTools.length > 0) {
|
|
250
|
+
addTools(report, jsTools, {
|
|
251
|
+
source: rel,
|
|
252
|
+
origin: "code-literal",
|
|
253
|
+
confidence: "medium",
|
|
254
|
+
note: "从 JS/TS 声明或 TypeBox/zod schema 静态提取(不执行代码),需人工确认"
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
227
258
|
if (TEST_PATH.test(rel)) continue;
|
|
228
259
|
// Only matches that carry a tool name are actionable; a bare `input_schema:`
|
|
229
260
|
// in API plumbing is not a tool and only inflates the report.
|
|
@@ -249,19 +280,46 @@ export async function scanProject(root = ".") {
|
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
|
|
283
|
+
if (retryFiles.size > 0 && constIndex.size > 0) {
|
|
284
|
+
for (const file of retryFiles) {
|
|
285
|
+
const text = await readText(file, report);
|
|
286
|
+
if (!text) continue;
|
|
287
|
+
const tools = extractJsTools(text, { externals: constIndex });
|
|
288
|
+
if (tools.length === 0) continue;
|
|
289
|
+
addTools(report, tools, {
|
|
290
|
+
source: relative(absRoot, file),
|
|
291
|
+
origin: "code-literal",
|
|
292
|
+
confidence: "medium",
|
|
293
|
+
note: "名称常量在其他模块定义,扫描完整仓库后再解析"
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
252
298
|
report.dependencies = [...dependencies].sort();
|
|
253
299
|
report.frameworks = [...frameworks].sort();
|
|
254
300
|
return report;
|
|
255
301
|
}
|
|
256
302
|
|
|
257
|
-
export function addTools(
|
|
258
|
-
|
|
303
|
+
export function addTools(
|
|
304
|
+
report,
|
|
305
|
+
tools,
|
|
306
|
+
{ source, origin, confidence = "high", note, meta, replaceExisting = false }
|
|
307
|
+
) {
|
|
308
|
+
const index = new Map(report.tools.map((tool, position) => [tool.name, position]));
|
|
259
309
|
const added = [];
|
|
260
310
|
for (const tool of tools) {
|
|
261
|
-
if (!tool?.name
|
|
262
|
-
existing.
|
|
263
|
-
|
|
264
|
-
|
|
311
|
+
if (!tool?.name) continue;
|
|
312
|
+
const existing = index.get(tool.name);
|
|
313
|
+
if (existing === undefined) {
|
|
314
|
+
index.set(tool.name, report.tools.length);
|
|
315
|
+
report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
|
|
316
|
+
added.push(tool.name);
|
|
317
|
+
} else if (replaceExisting) {
|
|
318
|
+
// A probed tool is the runtime truth; a static guess with the same name
|
|
319
|
+
// should be replaced, not silently kept.
|
|
320
|
+
report.tools[existing] = { ...tool, source, origin, ...(meta ?? {}) };
|
|
321
|
+
added.push(tool.name);
|
|
322
|
+
}
|
|
265
323
|
}
|
|
266
324
|
report.sources.push({
|
|
267
325
|
type: origin,
|
|
@@ -294,7 +352,9 @@ export function buildSuiteDraft(report) {
|
|
|
294
352
|
})),
|
|
295
353
|
cases: []
|
|
296
354
|
};
|
|
297
|
-
if (report.skills.length > 0)
|
|
355
|
+
if (report.skills.length > 0) {
|
|
356
|
+
draft.skills = report.skills.map((skill) => ({ name: skill.name, description: skill.description }));
|
|
357
|
+
}
|
|
298
358
|
// Where a tool came from is the only mount hint the scan can observe without
|
|
299
359
|
// guessing: one MCP server or one spec file is normally one thing agents mount.
|
|
300
360
|
const profiles = groupBySource(report.tools);
|
|
@@ -354,7 +414,7 @@ export function formatScanSummary(report) {
|
|
|
354
414
|
lines.push(
|
|
355
415
|
"",
|
|
356
416
|
`tools: ${report.tools.length}`,
|
|
357
|
-
`skills: ${report.skills.length}
|
|
417
|
+
`skills: ${report.skills.length}(仅静态检查)`,
|
|
358
418
|
`mcp servers: ${report.mcpServers.length}${probed > 0 ? `(已 probe ${probed} 个)` : "(需 --probe 才能拿到工具清单)"}`,
|
|
359
419
|
`code match: ${report.codeMatches.length}(low confidence,需人工确认)`
|
|
360
420
|
);
|
|
@@ -385,8 +445,11 @@ export function formatScanSummary(report) {
|
|
|
385
445
|
return lines.join("\n");
|
|
386
446
|
}
|
|
387
447
|
|
|
388
|
-
async function walk(dir, depth, files) {
|
|
389
|
-
if (depth > MAX_DEPTH || files.length >= MAX_FILES)
|
|
448
|
+
async function walk(dir, depth, files, state) {
|
|
449
|
+
if (depth > MAX_DEPTH || files.length >= MAX_FILES) {
|
|
450
|
+
if (files.length >= MAX_FILES) state.truncated = true;
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
390
453
|
let entries;
|
|
391
454
|
try {
|
|
392
455
|
entries = await readdir(dir, { withFileTypes: true });
|
|
@@ -395,11 +458,14 @@ async function walk(dir, depth, files) {
|
|
|
395
458
|
}
|
|
396
459
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
397
460
|
for (const entry of entries) {
|
|
398
|
-
if (files.length >= MAX_FILES)
|
|
461
|
+
if (files.length >= MAX_FILES) {
|
|
462
|
+
state.truncated = true;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
399
465
|
const full = join(dir, entry.name);
|
|
400
466
|
if (entry.isDirectory()) {
|
|
401
467
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
402
|
-
await walk(full, depth + 1, files);
|
|
468
|
+
await walk(full, depth + 1, files, state);
|
|
403
469
|
} else if (entry.isFile()) {
|
|
404
470
|
files.push(full);
|
|
405
471
|
}
|
package/lib/suite.js
CHANGED
|
@@ -3,10 +3,10 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
const EMPTY_SCHEMA = { type: "object", properties: {} };
|
|
5
5
|
|
|
6
|
-
export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
|
|
6
|
+
export async function loadSuite(suitePath, { allowEmptyCases = false, allowEmptyTools = false } = {}) {
|
|
7
7
|
const abs = resolve(suitePath);
|
|
8
8
|
const raw = JSON.parse(await readFile(abs, "utf8"));
|
|
9
|
-
if (!Array.isArray(raw.tools) || raw.tools.length === 0) {
|
|
9
|
+
if (!Array.isArray(raw.tools) || (raw.tools.length === 0 && !allowEmptyTools)) {
|
|
10
10
|
throw new Error(`suite ${abs}: "tools" must be a non-empty array`);
|
|
11
11
|
}
|
|
12
12
|
if (!Array.isArray(raw.cases) || (raw.cases.length === 0 && !allowEmptyCases)) {
|
|
@@ -29,7 +29,7 @@ export async function loadSuite(suitePath, { allowEmptyCases = false } = {}) {
|
|
|
29
29
|
tools,
|
|
30
30
|
cases,
|
|
31
31
|
profiles: raw.profiles ?? null,
|
|
32
|
-
skills: Array.isArray(raw.skills) ? raw.skills.map(
|
|
32
|
+
skills: Array.isArray(raw.skills) ? raw.skills.map(normalizeSkill) : [],
|
|
33
33
|
execution:
|
|
34
34
|
raw.execution && typeof raw.execution === "object" && !Array.isArray(raw.execution)
|
|
35
35
|
? { ...raw.execution }
|
|
@@ -58,6 +58,23 @@ async function normalizeTool(entry, baseDir) {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// Skills carry no parameter schema in the prompt-injected form (SKILL.md
|
|
62
|
+
// frontmatter), so the only checkable surface is name + description. Both the
|
|
63
|
+
// bare-name shorthand and the full form are accepted.
|
|
64
|
+
function normalizeSkill(entry, index) {
|
|
65
|
+
if (typeof entry === "string") {
|
|
66
|
+
const name = entry.trim();
|
|
67
|
+
if (!name) throw new Error(`skill #${index + 1}: name must be a non-empty string`);
|
|
68
|
+
return { name, description: "" };
|
|
69
|
+
}
|
|
70
|
+
if (entry && typeof entry === "object") {
|
|
71
|
+
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
|
72
|
+
if (!name) throw new Error(`skill #${index + 1}: "name" must be a non-empty string`);
|
|
73
|
+
return { name, description: typeof entry.description === "string" ? entry.description : "" };
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`skill #${index + 1}: must be a name or {name, description}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
61
78
|
function normalizeCase(entry, index, toolNames) {
|
|
62
79
|
const id = entry.id ?? `case_${index + 1}`;
|
|
63
80
|
if (typeof entry.input !== "string" || entry.input.trim() === "") {
|
package/mf.js
CHANGED
|
@@ -50,10 +50,10 @@ Usage:
|
|
|
50
50
|
mf scan [options]
|
|
51
51
|
mf case add --suite <file> (--input <file> | stdin) [--source agent] [--reviewed] [--dry-run]
|
|
52
52
|
mf coverage [--suite <file>] [--json]
|
|
53
|
-
mf lint [--suite <file>] [--json]
|
|
53
|
+
mf lint [--suite <file>] [--json] # 静态检查 tools 与 skills,不调用模型
|
|
54
54
|
mf compare <before.json> <after.json> [--json]
|
|
55
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] # 建议挂载分组
|
|
56
|
+
mf cluster [--suite <file>] [--threshold <0.5>] [--max-size <10>] [--out <file>] [--json] # 建议挂载分组 + 同族候选
|
|
57
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]
|
|
58
58
|
mf sandbox check [--json] # probe available isolation engines on this machine
|
|
59
59
|
mf mcp # start stdio MCP server for coding agents
|
|
@@ -344,6 +344,7 @@ export async function runScan(options, io = console) {
|
|
|
344
344
|
source: server.source,
|
|
345
345
|
origin: "mcp-probe",
|
|
346
346
|
confidence: "high",
|
|
347
|
+
replaceExisting: true,
|
|
347
348
|
note: `server ${server.name}`,
|
|
348
349
|
meta: { provider: server.name }
|
|
349
350
|
});
|
|
@@ -408,8 +409,11 @@ export async function runCaseAdd(options, io = console) {
|
|
|
408
409
|
|
|
409
410
|
export async function runCoverage(options, io = console) {
|
|
410
411
|
// 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), {
|
|
412
|
+
// a user needs right after scanning. A skills-only suite is also valid.
|
|
413
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
414
|
+
allowEmptyCases: true,
|
|
415
|
+
allowEmptyTools: true
|
|
416
|
+
});
|
|
413
417
|
const report = computeCoverage(suite);
|
|
414
418
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatCoverage(report));
|
|
415
419
|
return 0;
|
|
@@ -422,7 +426,10 @@ export async function runSandboxCheck(options, io = console) {
|
|
|
422
426
|
}
|
|
423
427
|
|
|
424
428
|
export async function runLint(options, io = console) {
|
|
425
|
-
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
429
|
+
const suite = await loadSuite(resolve(options.suite ?? DEFAULT_SUITE), {
|
|
430
|
+
allowEmptyCases: true,
|
|
431
|
+
allowEmptyTools: true
|
|
432
|
+
});
|
|
426
433
|
const report = lintSuite(suite);
|
|
427
434
|
io.log(options.json ? JSON.stringify(report, null, 2) : formatLint(report));
|
|
428
435
|
return report.counts.error > 0 ? 1 : 0;
|