modelfunction-cli 0.5.2 → 0.5.3

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 CHANGED
@@ -138,7 +138,7 @@ mf eval --suite mf.suite.json --models internal-ds \
138
138
 
139
139
  对外 JSON 字段、`check()` 返回结构、MCP 参数与退出码见 [SCHEMA.md](SCHEMA.md),自 0.4.3 起冻结为 v1:v1 内只做加法,重命名或删除字段需 major 并提前两个 minor 或 90 天弃用。`check()` 的类型声明随包发布。
140
140
 
141
- `0.5.1` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP。本仓库 `0.5.2` 待发布:MCP 补齐 `lint`/`check`/`cluster`/`compare`,安装门禁支持 `--manifests` 一包批量检查,门禁 warn 只保留混淆对/必填来源/结构性错误(描述类降 info)。示例统一使用 `@0.5.1`。
141
+ `0.5.2` 已发布到 npm:含凭证与本地反馈、usage `ci` 标记、eval `--progress`/`--max-cost`、`mf audit`、`scan --probe` streamable HTTP、MCP 静态工具 `lint`/`check`/`cluster`/`compare`、`checkMany` 一包批量检查,门禁 warn 只保留混淆对/必填来源/结构性错误。本仓库 `0.5.3` 待发布:静态规则性能(真实 100 能力清单 `checkMany` 471ms→40ms,550 能力 9.6s→2.1s,输出与 golden 一致),以及 `installed` 形状校验(接受 `mf scan --json` 输出,无法识别时报错而不是静默返回假 pass)和 `profiles` 兼容平台形态(对象或 `[{agentType, tools, skills}]` 数组)。示例统一使用 `@0.5.2`。
142
142
 
143
143
  ## 数据边界
144
144
 
package/SCHEMA.md CHANGED
@@ -73,7 +73,7 @@ skills 只做静态检查,不参与模型评测。
73
73
 
74
74
  ### profiles
75
75
 
76
- 组名 → 工具名数组。所有工具名必须存在;组可重叠。`mf eval --profile` 与 `mf ablate --profile` 依赖它。
76
+ 组名 → 工具名数组(`{ "group": ["tool", ...] }`)。也接受平台常见的两种形态:对象 `{ "0": { agentType, tools, skills } }` 与数组 `[{ agentType, tools, skills }]`,组名依次取 `name` → `agentType` → 键/下标;`skills` 字段目前被忽略(profiles 只用于工具挂载与 eval 分组)。所有工具名必须存在;组可重叠,组名不可重复。`mf eval --profile` 与 `mf ablate --profile` 依赖它。
77
77
 
78
78
  ### execution
79
79
 
@@ -112,7 +112,7 @@ skills 只做静态检查,不参与模型评测。
112
112
 
113
113
  report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` / `confidence` / `source` / `note?` / `descriptionSource?`)、`skills[]`、`mcpServers[]`(env 只保留 `envKeys`,headers 只保留 `headerKeys`;URL 去掉 userinfo / query / hash)、`probes[]`(`server` / `ok` / `toolCount?` / `tools?` / `error?`)、`sources[]`、`warnings[]`。`--probe` 支持 stdio 与 streamable HTTP 两种传输。
114
114
 
115
- `scan --json` 在未找到任何工具时退出码为 3。
115
+ `scan --json` 在工具与 skill 都未找到时退出码为 3(只有 skill 也算有结果,退出 0)。
116
116
 
117
117
  ## 4. lint JSON
118
118
 
@@ -159,7 +159,7 @@ report:`root`、`tools[]`(`name` / `description` / `parameters` / `origin` /
159
159
  import { check } from "modelfunction-cli";
160
160
  ```
161
161
 
162
- 输入 `{ manifest, installed }`:`manifest` 是一个能力(`name` 必填,`description` 可选,`parameters` / `inputSchema` / `input` 任一为 JSON Schema;无 schema 视为 skill)。`installed` 可以是能力数组,或 `{ tools: [], skills: [] }`。
162
+ 输入 `{ manifest, installed }`:`manifest` 是一个能力(`name` 必填,`description` 可选,`parameters` / `inputSchema` / `input` 任一为 JSON Schema;无 schema 视为 skill)。`installed` 可以是能力数组、`{ tools: [], skills: [] }`,或直接传 `mf scan --json` 的输出(能力在 `report` 下)。空对象视为没有已装能力;其他无法识别的形状会抛错,避免静默返回假的 `pass`。
163
163
 
164
164
  返回:
165
165
 
@@ -175,8 +175,9 @@ import { check } from "modelfunction-cli";
175
175
 
176
176
  - `verdict` 只有 `pass` / `warn`,**没有 fail**;质量判定不阻断安装。
177
177
  - **严重度策略(门禁语境)**:只有会改变模型行为的规则保持原等级——`desc-missing`、`param-type-missing`(error),`required-unsourced`、`pair-no-boundary`、`skill-pair-no-boundary`(warn);描述、命名、必填数量等质量规则降为 `info`,避免成熟工具集每次安装都告警。`mf lint` 不受影响,仍输出原始严重度。
178
- - 唯一抛错场景:`manifest.name` 缺失或为空。
178
+ - 抛错场景:`manifest.name` 缺失或为空;`installed` 不是上述三种形状之一(空对象仍视为无已装能力)。
179
179
  - 同步、无网络、无文件系统、无状态。类型声明见 `lib/check.d.ts`。
180
+ - 性能(0.5.3):每个能力只分词一次,且只比较涉及新能力的对。真实 100 能力清单(53 新 + 47 已装)约 40–50ms,47 工具对 53 skill 约 25ms;550 个高度同质的合成最坏情况约 2s。
180
181
 
181
182
  批量(一次安装一包):
182
183
 
@@ -229,7 +230,7 @@ const result = checkMany({ manifests: [/* ... */], installed: [/* ... */] });
229
230
  | 0 | 成功(`mf check` 的 warn 也是 0) |
230
231
  | 1 | 输入/用法错误;`mf compare` 判定不可直接比较 |
231
232
  | 2 | `mf eval --fail-under` 未达标、`--fail-on-regression` 发现回退,或 `--max-cost` 超限 |
232
- | 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具 |
233
+ | 3 | `mf exec` 被权限策略拒绝;`mf scan --json` 未找到工具或 skill |
233
234
  | 4 | `mf exec` 超时 |
234
235
 
235
236
  ## 9. 本地文件(0.5.0 起)
package/lib/cases.js CHANGED
@@ -207,38 +207,52 @@ export function formatCoverage(report) {
207
207
  return lines.join("\n");
208
208
  }
209
209
 
210
- export function pairSimilarity(x, y, { extraStop } = {}) {
211
- const a = tokens(`${x.name} ${x.description}`, extraStop);
212
- const b = tokens(`${y.name} ${y.description}`, extraStop);
213
- const union = new Set([...a, ...b]);
214
- let lexical = 0;
215
- if (union.size > 0) {
216
- let shared = 0;
217
- for (const token of union) if (a.has(token) && b.has(token)) shared += 1;
218
- lexical = shared / union.size;
219
- }
220
- const structural = structuralScore(x, y);
210
+ // O(n²) callers prepare each capability once; the pair loop then only compares
211
+ // sets. Tokenizing inside the loop made a 550-capability gate take ~14s.
212
+ export function prepareItem(item, { extraStop } = {}) {
213
+ const names = nameTokens(item.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
214
+ return {
215
+ name: item.name,
216
+ tokens: tokens(`${item.name} ${item.description}`, extraStop),
217
+ nameTokens: names,
218
+ nameTokenSet: new Set(names),
219
+ params: paramNames(item),
220
+ required: new Set(item.parameters?.required ?? [])
221
+ };
222
+ }
223
+
224
+ export function pairSimilarityPrepared(a, b) {
225
+ const shared = intersectionSize(a.tokens, b.tokens);
226
+ const union = a.tokens.size + b.tokens.size - shared;
227
+ const lexical = union > 0 ? shared / union : 0;
228
+ const structural = intersects(a.nameTokenSet, b.nameTokenSet) ? structuralScorePrepared(a, b) : 0;
221
229
  return {
222
230
  similarity: Math.max(lexical, structural),
223
231
  basis: structural > lexical ? "structural" : "lexical"
224
232
  };
225
233
  }
226
234
 
235
+ export function pairSimilarity(x, y, { extraStop } = {}) {
236
+ return pairSimilarityPrepared(prepareItem(x, { extraStop }), prepareItem(y, { extraStop }));
237
+ }
238
+
227
239
  // Clustering wants "same family" (alarm_query / alarm_ack belong to one agent),
228
240
  // which is looser than "confusable" (lint's question). Shared name token plus
229
241
  // any parameter overlap is enough to belong together; two shared tokens are
230
242
  // enough on their own (fault_alarm_query / fault_alarm_ack take different ids).
231
243
  // One shared token with disjoint params is not: cross-domain tools often share
232
244
  // a bare verb (query / list) and nothing else.
233
- export function familyScore(x, y) {
234
- const left = nameTokens(x.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
235
- const right = nameTokens(y.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
236
- const shared = left.filter((token) => right.includes(token));
237
- if (shared.length === 0) return 0;
238
- const paramOverlap = jaccard(paramNames(x), paramNames(y));
245
+ export function familyScorePrepared(x, y) {
246
+ const shared = intersectionSize(x.nameTokenSet, y.nameTokenSet);
247
+ if (shared === 0) return 0;
248
+ const paramOverlap = jaccard(x.params, y.params);
239
249
  if (paramOverlap > 0) return 0.8;
240
- if (shared.length >= 2) return 0.7;
241
- return paramNames(x).size === 0 && paramNames(y).size === 0 ? 0.6 : 0.2;
250
+ if (shared >= 2) return 0.7;
251
+ return x.params.size === 0 && y.params.size === 0 ? 0.6 : 0.2;
252
+ }
253
+
254
+ export function familyScore(x, y) {
255
+ return familyScorePrepared(prepareItem(x), prepareItem(y));
242
256
  }
243
257
 
244
258
  // Turn a confusable pair into an authoring instruction. We do not invent the
@@ -280,12 +294,13 @@ export function boundaryHint(x, y) {
280
294
  }
281
295
 
282
296
  export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop } = {}) {
297
+ const prepared = tools.map((tool) => prepareItem(tool, { extraStop }));
283
298
  const pairs = [];
284
- for (let i = 0; i < tools.length; i += 1) {
285
- for (let j = i + 1; j < tools.length; j += 1) {
286
- const { similarity, basis } = pairSimilarity(tools[i], tools[j], { extraStop });
299
+ for (let i = 0; i < prepared.length; i += 1) {
300
+ for (let j = i + 1; j < prepared.length; j += 1) {
301
+ const { similarity, basis } = pairSimilarityPrepared(prepared[i], prepared[j]);
287
302
  if (similarity >= threshold) {
288
- pairs.push({ a: tools[i].name, b: tools[j].name, similarity, basis });
303
+ pairs.push({ a: prepared[i].name, b: prepared[j].name, similarity, basis });
289
304
  }
290
305
  }
291
306
  }
@@ -296,20 +311,13 @@ export function suspectedPairs(tools, { threshold = PAIR_SIMILARITY, extraStop }
296
311
  // parameter shape confuse the model even when the wording differs, which the
297
312
  // lexical score alone misses. Name order is unreliable: `tavily_search` leads
298
313
  // with the vendor, `search_docs` with the action.
299
- function structuralScore(x, y) {
300
- const left = nameTokens(x.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
301
- const right = nameTokens(y.name).filter((token) => !GENERIC_NAME_TOKENS.has(token));
302
- const sharedName = left.some((token) => right.includes(token));
314
+ function structuralScorePrepared(x, y) {
315
+ const sharedName = x.nameTokens.some((token) => y.nameTokens.includes(token));
303
316
  if (!sharedName) return 0;
304
- const params = paramNames(x);
305
- const otherParams = paramNames(y);
306
- if (params.size === 0 && otherParams.size === 0) return 0;
307
- const paramOverlap = jaccard(params, otherParams);
317
+ if (x.params.size === 0 && y.params.size === 0) return 0;
318
+ const paramOverlap = jaccard(x.params, y.params);
308
319
  if (paramOverlap < 0.34) return 0;
309
- const requiredOverlap = jaccard(
310
- new Set(x.parameters?.required ?? []),
311
- new Set(y.parameters?.required ?? [])
312
- );
320
+ const requiredOverlap = jaccard(x.required, y.required);
313
321
  return 0.5 + 0.5 * (0.6 * paramOverlap + 0.4 * requiredOverlap);
314
322
  }
315
323
 
@@ -340,10 +348,26 @@ function paramNames(tool) {
340
348
 
341
349
  function jaccard(a, b) {
342
350
  if (a.size === 0 && b.size === 0) return 1;
343
- const union = new Set([...a, ...b]);
351
+ const shared = intersectionSize(a, b);
352
+ return shared / (a.size + b.size - shared);
353
+ }
354
+
355
+ // Iterate the smaller set; avoids allocating a union Set for every pair in the
356
+ // O(n²) loops.
357
+ function intersectionSize(a, b) {
358
+ const small = a.size <= b.size ? a : b;
359
+ const large = small === a ? b : a;
344
360
  let shared = 0;
345
- for (const item of union) if (a.has(item) && b.has(item)) shared += 1;
346
- return shared / union.size;
361
+ for (const item of small) if (large.has(item)) shared += 1;
362
+ return shared;
363
+ }
364
+
365
+ function intersects(a, b) {
366
+ if (a.size === 0 || b.size === 0) return false;
367
+ const small = a.size <= b.size ? a : b;
368
+ const large = small === a ? b : a;
369
+ for (const item of small) if (large.has(item)) return true;
370
+ return false;
347
371
  }
348
372
 
349
373
  function tokens(text, extraStop) {
package/lib/check.d.ts CHANGED
@@ -17,9 +17,14 @@ export interface InstalledCapabilities {
17
17
  skills?: Capability[];
18
18
  }
19
19
 
20
+ /** `mf scan --json` output; its capabilities live under `report`. */
21
+ export interface ScanReport {
22
+ report: InstalledCapabilities;
23
+ }
24
+
20
25
  export interface CheckInput {
21
26
  manifest: Capability;
22
- installed?: Capability[] | InstalledCapabilities;
27
+ installed?: Capability[] | InstalledCapabilities | ScanReport;
23
28
  }
24
29
 
25
30
  export interface CheckFinding {
@@ -48,7 +53,7 @@ export interface CheckResult {
48
53
 
49
54
  export interface CheckManyInput {
50
55
  manifests: Capability[];
51
- installed?: Capability[] | InstalledCapabilities;
56
+ installed?: Capability[] | InstalledCapabilities | ScanReport;
52
57
  }
53
58
 
54
59
  export interface CheckItemResult extends CheckResult {
@@ -66,14 +71,16 @@ export interface CheckManyResult {
66
71
  /**
67
72
  * Static install gate: judge one new capability against installed ones.
68
73
  * No model call, no network, no file system, no state.
69
- * @throws only when `manifest.name` is missing or empty.
74
+ * @throws when `manifest.name` is missing or empty, or `installed` is neither
75
+ * an array, `{ tools, skills }`, nor an `mf scan --json` report.
70
76
  */
71
77
  export declare function check(input: CheckInput): CheckResult;
72
78
 
73
79
  /**
74
80
  * Batch install gate: judge a pack of new capabilities against installed ones
75
81
  * and against each other.
76
- * @throws when manifests is empty, an item lacks a name, or names are duplicated.
82
+ * @throws when manifests is empty, an item lacks a name, names are duplicated,
83
+ * or `installed` is not an accepted shape.
77
84
  */
78
85
  export declare function checkMany(input: CheckManyInput): CheckManyResult;
79
86
 
package/lib/check.js CHANGED
@@ -40,12 +40,32 @@ export function checkMany({ manifests, installed = [] } = {}) {
40
40
  };
41
41
  }
42
42
 
43
- function evaluateTargets(targets, installed) {
44
- const pool = [];
45
- if (Array.isArray(installed)) pool.push(...installed);
46
- else if (installed && typeof installed === "object") {
47
- pool.push(...(installed.tools ?? []), ...(installed.skills ?? []));
43
+ // A gate that silently ignores a mis-shaped `installed` returns a false pass —
44
+ // the worst failure mode for an install check. Accept the array and
45
+ // {tools,skills} forms, plus `mf scan --json` output (wrapped in `report`);
46
+ // anything else non-empty is an input error.
47
+ function installedPool(installed) {
48
+ if (installed == null) return [];
49
+ if (Array.isArray(installed)) return installed;
50
+ if (typeof installed !== "object") {
51
+ throw new Error("installed 需要能力数组、{ tools, skills } 或 mf scan --json 的 report");
52
+ }
53
+ if (Object.keys(installed).length === 0) return [];
54
+ const source =
55
+ installed.report && typeof installed.report === "object" ? installed.report : installed;
56
+ const tools = source.tools ?? [];
57
+ const skills = source.skills ?? [];
58
+ if (!Array.isArray(tools) || !Array.isArray(skills)) {
59
+ throw new Error("installed.tools / installed.skills 必须是数组");
60
+ }
61
+ if (!Array.isArray(source.tools) && !Array.isArray(source.skills)) {
62
+ throw new Error("installed 需要能力数组、{ tools, skills } 或 mf scan --json 的 report");
48
63
  }
64
+ return [...tools, ...skills];
65
+ }
66
+
67
+ function evaluateTargets(targets, installed) {
68
+ const pool = installedPool(installed);
49
69
  const entries = pool.map(normalizeEntry).filter(Boolean);
50
70
 
51
71
  const tools = [];
@@ -53,7 +73,10 @@ function evaluateTargets(targets, installed) {
53
73
  for (const entry of entries) (isToolLike(entry) ? tools : skills).push(entry);
54
74
  for (const target of targets) (isToolLike(target) ? tools : skills).push(target);
55
75
 
56
- const report = lintSuite({ tools, skills });
76
+ // The gate only reports findings touching the new capabilities; skipping
77
+ // installed × installed pairs keeps a 500-capability pool linear-ish.
78
+ const pairScope = new Set(targets.map((target) => target.name));
79
+ const report = lintSuite({ tools, skills }, { pairScope });
57
80
  const pairFindings = report.findings.filter((finding) => finding.pair);
58
81
  const results = targets.map((target) => {
59
82
  const findings = report.findings
@@ -102,6 +125,11 @@ function normalizeEntry(entry) {
102
125
  return {
103
126
  name,
104
127
  description: typeof entry.description === "string" ? entry.description : "",
128
+ // Scan marks descriptions assembled at runtime; without this the gate
129
+ // treats every Claude-Code-style tool as "description missing".
130
+ ...(typeof entry.descriptionSource === "string"
131
+ ? { descriptionSource: entry.descriptionSource }
132
+ : {}),
105
133
  parameters: parameters && typeof parameters === "object" ? parameters : null
106
134
  };
107
135
  }
package/lib/cluster.js CHANGED
@@ -1,4 +1,10 @@
1
- import { pairSimilarity, familyScore, nameTokens, GENERIC_NAME_TOKENS } from "./cases.js";
1
+ import {
2
+ prepareItem,
3
+ pairSimilarityPrepared,
4
+ familyScorePrepared,
5
+ nameTokens,
6
+ GENERIC_NAME_TOKENS
7
+ } from "./cases.js";
2
8
 
3
9
  // A shared domain noun ("session", "goal", "tool") says these tools serve one
4
10
  // purpose even when their verbs and parameter shapes differ — exactly what
@@ -21,12 +27,13 @@ const BOUNDARY_FAMILY = 0.6;
21
27
 
22
28
  export function clusterTools(tools, { threshold = 0.5, maxSize = 10 } = {}) {
23
29
  const scores = tools.map(() => new Array(tools.length).fill(0));
30
+ const prepared = tools.map((tool) => prepareItem(tool));
24
31
  const pairs = [];
25
32
  const edges = [];
26
33
  for (let i = 0; i < tools.length; i += 1) {
27
34
  for (let j = i + 1; j < tools.length; j += 1) {
28
- const { similarity, basis } = pairSimilarity(tools[i], tools[j]);
29
- const family = familyScore(tools[i], tools[j]);
35
+ const { similarity, basis } = pairSimilarityPrepared(prepared[i], prepared[j]);
36
+ const family = familyScorePrepared(prepared[i], prepared[j]);
30
37
  const link = Math.max(similarity, family);
31
38
  scores[i][j] = link;
32
39
  scores[j][i] = link;
package/lib/lint.js CHANGED
@@ -57,7 +57,12 @@ const BOUNDARY_HINT = /(不适用|不要用于|不用于|不能用于|而非|区
57
57
  // Chinese descriptions are compared as bigrams, so mid-0.3s similarity is noise.
58
58
  const NO_BOUNDARY_SIMILARITY = 0.5;
59
59
 
60
- export function lintSuite(suite) {
60
+ // `pairScope` (a Set of names) is for incremental gates: only emit pair
61
+ // findings that touch one of the new capabilities. Installed × installed pairs
62
+ // are computed and then discarded by the caller, which dominates large sets.
63
+ export function lintSuite(suite, { pairScope } = {}) {
64
+ const inScope = (pair) => !pairScope || pairScope.has(pair.a) || pairScope.has(pair.b);
65
+ const toolByName = new Map(suite.tools.map((tool) => [tool.name, tool]));
61
66
  const findings = [];
62
67
  const add = (severity, code, tool, message, extra = {}) =>
63
68
  findings.push({ severity, code, kind: "tool", tool, message, ...extra });
@@ -205,9 +210,10 @@ export function lintSuite(suite) {
205
210
  }
206
211
 
207
212
  for (const pair of suspectedPairs(suite.tools)) {
213
+ if (!inScope(pair)) continue;
208
214
  const similarity = pair.similarity.toFixed(2);
209
215
  const declaresBoundary = [pair.a, pair.b].some((name) => {
210
- const tool = suite.tools.find((item) => item.name === name);
216
+ const tool = toolByName.get(name);
211
217
  return BOUNDARY_HINT.test(tool?.description ?? "");
212
218
  });
213
219
  if (declaresBoundary) {
@@ -223,8 +229,8 @@ export function lintSuite(suite) {
223
229
  }
224
230
  }
225
231
  const reason = pair.basis === "structural" ? "名字与参数形状接近" : "描述相近";
226
- const toolA = suite.tools.find((tool) => tool.name === pair.a);
227
- const toolB = suite.tools.find((tool) => tool.name === pair.b);
232
+ const toolA = toolByName.get(pair.a);
233
+ const toolB = toolByName.get(pair.b);
228
234
  add("warn", "pair-no-boundary", pair.a, `与 ${pair.b} ${reason}(相似度 ${similarity}),且两边都没写“不适用于”边界,模型只能靠相似度硬猜`, {
229
235
  pair: [pair.a, pair.b],
230
236
  suggestion: boundaryHint(toolA, toolB).suggestion
@@ -236,6 +242,7 @@ export function lintSuite(suite) {
236
242
  // question is narrower than for tools: is the description specific enough to
237
243
  // be triggered, and does it collide with another installed skill?
238
244
  const skills = suite.skills ?? [];
245
+ const skillByName = new Map(skills.map((skill) => [skill.name, skill]));
239
246
  for (const skill of skills) {
240
247
  const description = String(skill.description ?? "").trim();
241
248
  if (!description) {
@@ -258,9 +265,10 @@ export function lintSuite(suite) {
258
265
  threshold: SKILL_PAIR_SIMILARITY,
259
266
  extraStop: SKILL_BOILERPLATE
260
267
  })) {
268
+ if (!inScope(pair)) continue;
261
269
  const similarity = pair.similarity.toFixed(2);
262
270
  const declaresBoundary = [pair.a, pair.b].some((name) => {
263
- const skill = skills.find((item) => item.name === name);
271
+ const skill = skillByName.get(name);
264
272
  return BOUNDARY_HINT.test(skill?.description ?? "");
265
273
  });
266
274
  // Same-vendor siblings ("feishu-doc" / "feishu-wiki") share the vendor token
package/lib/profile.js CHANGED
@@ -2,17 +2,28 @@ import { pct } from "./score.js";
2
2
 
3
3
  export function normalizeProfiles(raw, toolNames) {
4
4
  if (raw === undefined || raw === null) return null;
5
- if (typeof raw !== "object" || Array.isArray(raw)) {
6
- throw new Error('suite "profiles" must be an object of { "group": ["tool", ...] }');
5
+ if (typeof raw !== "object") {
6
+ throw new Error('suite "profiles" must be an object or an array of groups');
7
7
  }
8
8
  const known = new Set(toolNames);
9
9
  const profiles = [];
10
- for (const [key, list] of Object.entries(raw)) {
11
- const name = String(key).trim();
10
+ const seen = new Set();
11
+ // Platform registries often hand us an array of
12
+ // { agentType, tools, skills } rather than { "group": ["tool"] }.
13
+ const entries = Array.isArray(raw)
14
+ ? raw.map((value, index) => [String(index), value])
15
+ : Object.entries(raw);
16
+ for (const [key, value] of entries) {
17
+ // Array form: { "group": ["tool", ...] }. Object form (platform registries
18
+ // often look like this): { "0": { agentType, tools, skills } }.
19
+ const list = Array.isArray(value) ? value : value?.tools;
20
+ const name = String(Array.isArray(value) ? key : value?.name ?? value?.agentType ?? key).trim();
12
21
  if (!name) throw new Error('suite "profiles": group name must be non-empty');
13
22
  if (!Array.isArray(list) || list.length === 0) {
14
23
  throw new Error(`profile "${name}": must be a non-empty array of tool names`);
15
24
  }
25
+ if (seen.has(name)) throw new Error(`profile "${name}": duplicate group name`);
26
+ seen.add(name);
16
27
  const tools = [];
17
28
  for (const item of list) {
18
29
  if (typeof item !== "string" || item.trim() === "") {
package/mf.js CHANGED
@@ -482,7 +482,7 @@ export async function runScan(options, io = console) {
482
482
 
483
483
  if (options.json) {
484
484
  io.log(JSON.stringify({ report: redactReport(report), draft }, null, 2));
485
- return report.tools.length > 0 ? 0 : 3;
485
+ return report.tools.length > 0 || report.skills.length > 0 ? 0 : 3;
486
486
  }
487
487
 
488
488
  io.log(formatScanSummary(report));
@@ -497,7 +497,7 @@ export async function runScan(options, io = console) {
497
497
  io.log(`wrote ${reportPath}`);
498
498
  }
499
499
 
500
- return report.tools.length > 0 ? 0 : 3;
500
+ return report.tools.length > 0 || report.skills.length > 0 ? 0 : 3;
501
501
  }
502
502
 
503
503
  export async function runCaseAdd(options, io = console) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelfunction-cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "mf — function-calling accuracy scorecard for AI agents",