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/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
- const MAX_FILES = 5000;
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
- await walk(absRoot, 0, files);
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(report, tools, { source, origin, confidence = "high", note, meta }) {
258
- const existing = new Set(report.tools.map((tool) => tool.name));
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 || existing.has(tool.name)) continue;
262
- existing.add(tool.name);
263
- report.tools.push({ ...tool, source, origin, ...(meta ?? {}) });
264
- added.push(tool.name);
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) draft.skills = report.skills.map((skill) => skill.name);
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) return;
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) return;
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(String) : [],
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), { allowEmptyCases: true });
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), { allowEmptyCases: true });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",