efaimo 0.1.2 → 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.
Files changed (45) hide show
  1. package/CHANGELOG.md +160 -1
  2. package/README.md +75 -6
  3. package/SKILL.md +14 -7
  4. package/dist/check/diff.d.ts +124 -0
  5. package/dist/check/diff.js +165 -0
  6. package/dist/cli.js +237 -22
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +144 -0
  10. package/dist/find/bm25.d.ts +77 -0
  11. package/dist/find/bm25.js +107 -0
  12. package/dist/find/find.d.ts +122 -0
  13. package/dist/find/find.js +405 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +7 -3
  17. package/dist/index.js +14 -3
  18. package/dist/reporters/json.d.ts +19 -1
  19. package/dist/reporters/json.js +13 -2
  20. package/dist/reporters/markdown.d.ts +2 -1
  21. package/dist/reporters/markdown.js +109 -11
  22. package/dist/reporters/pretty.d.ts +25 -1
  23. package/dist/reporters/pretty.js +228 -9
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/skill/index.js +32 -1
  27. package/dist/rules/version.d.ts +26 -0
  28. package/dist/rules/version.js +26 -0
  29. package/dist/testing/anthropicRunner.d.ts +30 -0
  30. package/dist/testing/anthropicRunner.js +43 -8
  31. package/dist/testing/harness.d.ts +32 -1
  32. package/dist/testing/harness.js +28 -3
  33. package/dist/testing/stats.d.ts +16 -0
  34. package/dist/testing/stats.js +26 -0
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/dist/weigh/anthropicExact.d.ts +12 -1
  38. package/dist/weigh/anthropicExact.js +9 -1
  39. package/dist/weigh/weigh.js +10 -3
  40. package/docs/INTEGRATIONS.md +15 -1
  41. package/docs/METHODOLOGY.md +110 -6
  42. package/docs/RULES.md +40 -3
  43. package/examples/scenario.crm.yaml +8 -0
  44. package/examples/scenario.example.yaml +8 -0
  45. package/package.json +3 -1
@@ -1,6 +1,7 @@
1
1
  import pc from "picocolors";
2
2
  import { formatWindowShare } from "../weigh/window.js";
3
3
  import { sortFindings } from "../core/grade.js";
4
+ import { minimumDetectableDelta } from "../testing/stats.js";
4
5
  import { VERSION } from "../version.js";
5
6
  import { safeText } from "../util/safeText.js";
6
7
  const DOCS_RULES = "https://github.com/efaimo-ai/efaimo/blob/main/docs/RULES.md";
@@ -32,8 +33,23 @@ function gradeColor(letter, text) {
32
33
  function n(x) {
33
34
  return x.toLocaleString("en-US");
34
35
  }
36
+ /**
37
+ * A value that has to stay inside one line of a table or one line of a
38
+ * finding.
39
+ *
40
+ * `safeText` strips control characters but deliberately keeps newlines,
41
+ * because a detail block is meant to be multi-line. That is the wrong default
42
+ * for a single cell: a tool named
43
+ * `nl_tool\n 9 forged_row weather, forecast` renders as a SECOND
44
+ * row byte-identical in shape to a real one, and under `--no-color`, which is
45
+ * how CI logs read, there is no tell at all. The audited thing must not be
46
+ * able to write rows in the auditor's table.
47
+ */
48
+ function cell(s) {
49
+ return safeText(s).replace(/[\r\n]+/g, " ");
50
+ }
35
51
  function renderFinding(lines, f) {
36
- lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${safeText(f.message)}`);
52
+ lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${cell(f.message)}`);
37
53
  if (f.detail) {
38
54
  for (const d of safeText(f.detail).split("\n"))
39
55
  lines.push(paint(pc.dim, ` ${d}`));
@@ -80,13 +96,23 @@ export function renderCheckPretty(report) {
80
96
  export function renderScenarioPlan(s) {
81
97
  const calls = s.trials * 2 * 2;
82
98
  const firstLine = s.task.split("\n")[0].slice(0, 60);
99
+ const mde = minimumDetectableDelta(s.trials);
83
100
  return [
84
101
  paint(pc.dim, `efaimo v${VERSION}`),
85
102
  `test (dry run) ${paint(pc.bold, s.name)}`,
86
103
  "",
87
104
  ` skill ${s.skillName}`,
88
105
  ` model ${s.model}`,
106
+ ` judge ${s.judgeModel}${s.judgeModel === s.model
107
+ ? paint(pc.yellow, " (same model grades its own answers; set judge_model or --judge-model to separate them)")
108
+ : ""}`,
89
109
  ` plan ${s.trials} trials x 2 arms x (task + judge) = ${paint(pc.bold, String(calls))} API calls`,
110
+ // What this plan cannot detect, before any tokens are spent. A run that
111
+ // cannot reach significance produces a green that means nothing, and the
112
+ // only useful moment to learn that is now.
113
+ ` power ${Number.isFinite(mde)
114
+ ? `the smallest gap this size can call significant is ${paint(mde > 40 ? pc.yellow : pc.dim, `${mde.toFixed(0)} points`)}${mde > 40 ? paint(pc.yellow, " (raise trials)") : ""}`
115
+ : paint(pc.red, "no outcome at this size can reach p < 0.05; raise trials")}`,
90
116
  ` task ${paint(pc.dim, firstLine + (s.task.length > 60 ? "..." : ""))}`,
91
117
  "",
92
118
  paint(pc.yellow, "dry run: no API calls made. add --live to run it (Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)."),
@@ -98,7 +124,7 @@ export function renderTestReportPretty(r) {
98
124
  const lines = [
99
125
  paint(pc.dim, `efaimo v${VERSION}`),
100
126
  `test ${paint(pc.bold, r.scenario)}`,
101
- `skill ${r.skill} model ${r.model}`,
127
+ `skill ${r.skill} model ${r.model} judge ${r.judgeModel}${r.judgeModel === r.model ? paint(pc.dim, " (same model)") : ""}`,
102
128
  "",
103
129
  ` with skill ${r.withSkill.passes}/${r.withSkill.scored} pass (${r.withSkill.passRate.toFixed(0)}%)`,
104
130
  ` without skill ${r.withoutSkill.passes}/${r.withoutSkill.scored} pass (${r.withoutSkill.passRate.toFixed(0)}%)`,
@@ -130,21 +156,21 @@ export function renderSkillSetPretty(res) {
130
156
  for (const s of [...res.perSkill].sort((a, b) => a.report.grade.score - b.report.grade.score)) {
131
157
  const g = s.report.grade;
132
158
  const c = s.report.counts;
133
- lines.push(` ${gradeColor(g.letter, `${g.letter} (${String(g.score).padStart(3)})`)} ${safeText(s.name).padEnd(nameW)} ` +
159
+ lines.push(` ${gradeColor(g.letter, `${g.letter} (${String(g.score).padStart(3)})`)} ${cell(s.name).padEnd(nameW)} ` +
134
160
  `${paint(pc.red, String(c.error))}e ${paint(pc.yellow, String(c.warn))}w ${paint(pc.cyan, String(c.info))}i`);
135
161
  }
136
162
  if (res.setFindings.length) {
137
163
  lines.push("");
138
164
  lines.push(paint(pc.bold, "across the set"));
139
165
  for (const f of sortFindings(res.setFindings)) {
140
- lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${safeText(f.message)}`);
166
+ lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${cell(f.message)}`);
141
167
  }
142
168
  }
143
169
  const flagged = res.perSkill.filter((s) => s.report.findings.length);
144
170
  if (flagged.length) {
145
171
  lines.push("");
146
172
  for (const s of flagged) {
147
- lines.push(paint(pc.bold, `${safeText(s.name)} ${gradeColor(s.report.grade.letter, s.report.grade.letter)}`));
173
+ lines.push(paint(pc.bold, `${cell(s.name)} ${gradeColor(s.report.grade.letter, s.report.grade.letter)}`));
148
174
  for (const f of s.report.findings) {
149
175
  lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${safeText(f.message)}`);
150
176
  if (f.fixHint)
@@ -170,7 +196,7 @@ export function renderServerWeighPretty(w) {
170
196
  if (w.instructionsTokens > 0)
171
197
  lines.push(` server instructions ${n(w.instructionsTokens).padStart(4)}`);
172
198
  if (w.anthropicExactTotal !== undefined) {
173
- lines.push(` ${paint(pc.green, "anthropic exact")} ${n(w.anthropicExactTotal).padStart(8)} (count_tokens API)`);
199
+ lines.push(` ${paint(pc.green, "anthropic exact")} ${n(w.anthropicExactTotal).padStart(8)} (count_tokens, ${safeText(w.anthropicExactModel ?? "model not recorded")})`);
174
200
  }
175
201
  if (w.perTool.length) {
176
202
  lines.push("");
@@ -183,9 +209,9 @@ export function renderServerWeighPretty(w) {
183
209
  // column in its own flagship demo. Capped so one pathological name cannot
184
210
  // push the numbers off a terminal.
185
211
  const shown = w.perTool.slice(0, 8);
186
- const toolW = Math.min(38, Math.max(20, ...shown.map((t) => safeText(t.name).length)));
212
+ const toolW = Math.min(38, Math.max(20, ...shown.map((t) => cell(t.name).length)));
187
213
  for (const [i, t] of shown.entries()) {
188
- lines.push(` ${String(i + 1).padStart(2)}. ${safeText(t.name).padEnd(toolW)} ${n(t.tokens.claudeStyle).padStart(7)} desc ${n(t.descriptionTokens)} | schema ${n(t.schemaTokens)}`);
214
+ lines.push(` ${String(i + 1).padStart(2)}. ${cell(t.name).padEnd(toolW)} ${n(t.tokens.claudeStyle).padStart(7)} desc ${n(t.descriptionTokens)} | schema ${n(t.schemaTokens)}`);
189
215
  }
190
216
  if (w.perTool.length > 8)
191
217
  lines.push(paint(pc.dim, ` (+${w.perTool.length - 8} more)`));
@@ -205,7 +231,7 @@ export function renderSkillWeighPretty(w) {
205
231
  lines.push("");
206
232
  lines.push(` ${"skill".padEnd(28)} ${"metadata".padStart(8)} ${"body".padStart(9)} ${"lines".padStart(6)} refs`);
207
233
  for (const s of w.perSkill) {
208
- lines.push(` ${safeText(s.name).padEnd(28)} ${n(s.metadataTokens).padStart(8)} ${n(s.bodyTokens).padStart(9)} ${String(s.bodyLines).padStart(6)} ${s.refFileCount ? `${s.refFileCount} files ${n(s.refFileTokens)}` : "-"}`);
234
+ lines.push(` ${cell(s.name).padEnd(28)} ${n(s.metadataTokens).padStart(8)} ${n(s.bodyTokens).padStart(9)} ${String(s.bodyLines).padStart(6)} ${s.refFileCount ? `${s.refFileCount} files ${n(s.refFileTokens)}` : "-"}`);
209
235
  }
210
236
  lines.push("");
211
237
  lines.push(`totals: metadata ${paint(pc.bold, n(w.totals.metadata))} (always loaded) | body ${n(w.totals.body)} (on trigger) | referenced ${n(w.totals.refFiles)} (on demand)`);
@@ -214,6 +240,151 @@ export function renderSkillWeighPretty(w) {
214
240
  lines.push(paint(pc.dim, `note: ${safeText(note)}`));
215
241
  return lines.join("\n");
216
242
  }
243
+ /**
244
+ * `efaimo find`.
245
+ *
246
+ * Two numbers, both as fractions with their denominators printed, neither a
247
+ * letter. The first is a property of the catalog; the second is a simulation
248
+ * and is labelled as one on the line itself, because a saturated 100% next to
249
+ * a real measurement reads like a good score unless the page says otherwise.
250
+ */
251
+ export function renderFindPretty(f) {
252
+ const lines = [];
253
+ lines.push(paint(pc.dim, `efaimo v${VERSION}`));
254
+ lines.push(`find mcp ${paint(pc.bold, safeText(f.label))}`);
255
+ lines.push(paint(pc.dim, `tools ${f.toolCount} result window ${f.method.topK} BM25 k1=${f.method.bm25.k1} b=${f.method.bm25.b}, ${f.method.queryTerms} query terms`));
256
+ lines.push("");
257
+ // Coloured on the rule condition, not on invented cutoffs. An earlier
258
+ // version banded this at 95 and 80 percent, two numbers that appear in no
259
+ // rule, no doc and no ADR, inside the one command whose whole design note
260
+ // says it does not grade. Red means E141 fired; that is a threshold the
261
+ // ruleset already owns.
262
+ const dpct = f.distinct.pct;
263
+ const anyOwnsNothing = f.distinct.count < f.distinct.total;
264
+ const dcolor = f.distinctVacuous ? pc.dim : anyOwnsNothing ? pc.red : pc.green;
265
+ const dFrac = `${f.distinct.count}/${f.distinct.total} (${dpct}%)`;
266
+ const pFrac = `${f.probe.returned}/${f.probe.total} (${f.probe.pct}%)`;
267
+ // Pad on the plain text, never on the painted string: colour codes are bytes
268
+ // that padEnd counts and a terminal does not, which is how a coloured column
269
+ // ends up crooked in exactly the demo everyone screenshots.
270
+ const fracW = Math.max(dFrac.length, pFrac.length);
271
+ lines.push(`distinct ${paint(dcolor, dFrac)}${" ".repeat(fracW - dFrac.length)}` +
272
+ ` ${paint(pc.dim, "tools that own a word no other tool has")}`);
273
+ if (f.distinctVacuous) {
274
+ lines.push(paint(pc.yellow, ` vacuous: a one-tool catalog has nothing to be distinct from, so this is 100% for any tool`));
275
+ }
276
+ lines.push(`probe ${paint(pc.dim, pFrac)}${" ".repeat(fracW - pFrac.length)}` +
277
+ ` ${paint(pc.dim, "returned by a simulated search for their own description")}`);
278
+ if (f.windowCoversCatalog) {
279
+ lines.push(paint(pc.yellow, ` vacuous: ${f.toolCount} tools fit inside a window of ${f.method.topK}, so none can fall outside it`));
280
+ }
281
+ lines.push("");
282
+ // Worst first. A reader scanning the top of the table should be looking at
283
+ // the tools that cannot be told apart, not at an alphabetical list.
284
+ const byWorst = [...f.perTool].sort((a, b) => {
285
+ const ar = a.rank ?? Number.POSITIVE_INFINITY;
286
+ const br = b.rank ?? Number.POSITIVE_INFINITY;
287
+ return (a.ownTermCount - b.ownTermCount ||
288
+ br - ar ||
289
+ (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
290
+ });
291
+ const shown = byWorst.slice(0, 15);
292
+ const nameW = Math.min(34, Math.max(12, ...shown.map((t) => cell(t.name).length)));
293
+ if (f.sources && f.sources.length > 1) {
294
+ // The whole point of a stack run is which server a colliding tool came
295
+ // from, so it goes beside the name rather than into a footnote.
296
+ lines.push(paint(pc.dim, ` merged ${f.sources.length} catalogs: ${f.sources.join(", ")}`));
297
+ }
298
+ lines.push(paint(pc.dim, ` words ${"tool".padEnd(nameW)} vocabulary no other tool has`));
299
+ for (const t of shown) {
300
+ const count = t.ownTermCount === 0
301
+ ? paint(pc.red, " 0")
302
+ : t.ownTermCount === 1
303
+ ? paint(pc.yellow, " 1")
304
+ : paint(pc.green, String(t.ownTermCount).padStart(5));
305
+ let tail;
306
+ if (t.ownTermCount === 0) {
307
+ tail = paint(pc.red, t.sharedWith.length ? `none: every word also on ${cell(t.sharedWith.join(", "))}` : "none");
308
+ }
309
+ else {
310
+ const more = t.ownTermCount - t.ownTerms.length;
311
+ tail = paint(pc.dim, cell(t.ownTerms.join(", ")) + (more > 0 ? ` (+${more})` : ""));
312
+ }
313
+ // The probe only ever appears as a flag on a row it failed. A column of
314
+ // "1" down the whole table would be a number that never varies presented
315
+ // as if it were a measurement.
316
+ const probe = t.rank === undefined
317
+ ? paint(pc.red, " [probe: no description]")
318
+ : t.reachable
319
+ ? ""
320
+ : paint(pc.red, ` [probe: rank ${t.rank}]`);
321
+ const from = t.origin ? paint(pc.dim, ` [${t.origin}]`) : "";
322
+ lines.push(` ${count} ${cell(t.name).padEnd(nameW)} ${tail}${probe}${from}`);
323
+ }
324
+ // Describe the hidden rows from the hidden rows, never from an assumption.
325
+ // This line used to read "(+N more, each owning at least one word)" without
326
+ // checking: on a catalog where all 18 tools owned nothing it printed that
327
+ // three lines under a `0/18` headline saying the opposite.
328
+ const hiddenRows = byWorst.slice(shown.length);
329
+ if (hiddenRows.length) {
330
+ const hiddenAtZero = hiddenRows.filter((t) => t.ownTermCount === 0).length;
331
+ lines.push(paint(pc.dim, ` (+${hiddenRows.length} more` +
332
+ (hiddenAtZero ? `, ${hiddenAtZero} of them owning no word` : ", each owning at least one word") +
333
+ `; --json for all of them)`));
334
+ }
335
+ return lines.join("\n");
336
+ }
337
+ /** The findings half of `efaimo find`, printed under the table. */
338
+ export function renderFindFindingsPretty(f, findings) {
339
+ const lines = [""];
340
+ if (!findings.length) {
341
+ lines.push(paint(pc.green, " no findability findings."));
342
+ }
343
+ else {
344
+ for (const x of sortFindings(findings))
345
+ renderFinding(lines, x);
346
+ }
347
+ lines.push("");
348
+ for (const note of f.notes) {
349
+ // The two vacuity notes are already printed inline, in yellow, on the row
350
+ // the reader is looking at. Repeating them here as prose was the same
351
+ // warning twice in one screen; the data still carries them for --json and
352
+ // --md, where there is no inline row to carry it.
353
+ if (note.startsWith("VACUOUS"))
354
+ continue;
355
+ for (const [i, line] of wrapNote(safeText(note)).entries()) {
356
+ lines.push(paint(pc.dim, i === 0 ? `note: ${line}` : ` ${line}`));
357
+ }
358
+ }
359
+ // What to do next. A clean run used to end on "no findability findings" and
360
+ // a rules URL, which tells a first-time reader nothing about how to keep it
361
+ // that way. `distinct` is named because it is the number that can fail at
362
+ // any catalog size; the probe cannot.
363
+ if (!f.distinctVacuous) {
364
+ lines.push(paint(pc.dim, `gate it: efaimo find "<server>" --min-distinct ${Math.floor(f.distinct.pct)}` +
365
+ (f.distinct.pct < 100 ? " (raise it as you fix the tools above)" : "")));
366
+ }
367
+ lines.push(paint(pc.dim, `rules: ${DOCS_RULES}`));
368
+ return lines.join("\n");
369
+ }
370
+ /** Soft-wrap a long note at ~92 columns on word boundaries, so terminals do not ragged-wrap mid-number. */
371
+ function wrapNote(s, width = 92) {
372
+ const out = [];
373
+ for (const paragraph of s.split("\n")) {
374
+ let line = "";
375
+ for (const word of paragraph.split(/\s+/)) {
376
+ if (line && line.length + 1 + word.length > width) {
377
+ out.push(line);
378
+ line = word;
379
+ }
380
+ else {
381
+ line = line ? `${line} ${word}` : word;
382
+ }
383
+ }
384
+ out.push(line);
385
+ }
386
+ return out;
387
+ }
217
388
  export function renderDiffPretty(d, opts) {
218
389
  const lines = [];
219
390
  const sign = d.delta >= 0 ? "+" : "";
@@ -232,3 +403,51 @@ export function renderDiffPretty(d, opts) {
232
403
  }
233
404
  return lines.join("\n");
234
405
  }
406
+ /**
407
+ * What moved between two `check` runs.
408
+ *
409
+ * The three token columns stay in three rows on purpose. Summing them gives a
410
+ * single percentage that is arithmetically true and describes none of the
411
+ * three costs, because metadata is carried permanently while referenced files
412
+ * load only on demand, and in practice almost all movement lands in the column
413
+ * that costs the least to hold. A dominant mover is printed beside its total
414
+ * for the same reason: when one subject is most of a change, the total is
415
+ * about that subject rather than about the population.
416
+ */
417
+ export function renderCheckDiffPretty(d) {
418
+ const lines = [];
419
+ lines.push(paint(pc.dim, `efaimo v${VERSION}`));
420
+ const rules = d.rulesVersions.before ?? "?";
421
+ lines.push(`diff check ${paint(pc.bold, `${d.paired} paired`)}${d.added.length ? `, ${d.added.length} added` : ""}${d.removed.length ? `, ${d.removed.length} removed` : ""}`);
422
+ lines.push(paint(pc.dim, d.rulesDrift
423
+ ? `efaimo ${d.versions.before} -> ${d.versions.after}, rules ${d.rulesVersions.before} -> ${d.rulesVersions.after} DRIFTED`
424
+ : `efaimo ${d.versions.before} -> ${d.versions.after}, rules ${rules} on both sides`));
425
+ lines.push("");
426
+ const sign = (n) => (n > 0 ? `+${n.toLocaleString("en-US")}` : n.toLocaleString("en-US"));
427
+ const pct = (n) => `${n > 0 ? "+" : ""}${n.toFixed(1)}%`;
428
+ lines.push(paint(pc.bold, "grades"));
429
+ if (d.rulesDrift) {
430
+ lines.push(` ${paint(pc.yellow, "unattributable")}: the ruleset changed between the runs, so a moved grade may be the ruler`);
431
+ }
432
+ const g = (m) => ` ${m.name.padEnd(34)} ${m.before.letter} (${m.before.score}) -> ${m.after.letter} (${m.after.score})`;
433
+ for (const m of d.improved)
434
+ lines.push(paint(pc.green, g(m)));
435
+ for (const m of d.worsened)
436
+ lines.push(paint(pc.red, g(m)));
437
+ lines.push(paint(pc.dim, ` improved ${d.improved.length} worsened ${d.worsened.length} held ${d.held} findings ${d.findingCounts.before} -> ${d.findingCounts.after}`));
438
+ lines.push("");
439
+ if (d.costs.length) {
440
+ lines.push(paint(pc.bold, "token cost, by when it loads"));
441
+ for (const c of d.costs) {
442
+ const color = c.delta > 0 ? pc.yellow : c.delta < 0 ? pc.green : pc.dim;
443
+ lines.push(` ${c.column.padEnd(11)} ${paint(pc.dim, `(${c.when})`.padEnd(20))} ${String(c.before.toLocaleString("en-US")).padStart(9)} -> ${String(c.after.toLocaleString("en-US")).padStart(9)} ${paint(color, pct(c.pct))}`);
444
+ if (c.dominatedBy) {
445
+ lines.push(paint(pc.dim, ` ${c.dominatedBy.name} is ${c.dominatedBy.shareOfMovement.toFixed(1)}% of that movement (${sign(c.dominatedBy.delta)}); the total describes it, not the rest`));
446
+ }
447
+ }
448
+ lines.push("");
449
+ }
450
+ for (const n of d.notes)
451
+ lines.push(paint(pc.dim, ` ${n}`));
452
+ return lines.join("\n");
453
+ }
@@ -0,0 +1,4 @@
1
+ import type { FindRule } from "../../core/types.js";
2
+ export declare const FIND_RULES: FindRule[];
3
+ /** E141-E146 are findability rules: reported by `efaimo find`, never part of a `check` grade. */
4
+ export declare function isFindabilityRuleId(id: string): boolean;
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Findability rules, E141-E146.
3
+ *
4
+ * A separate family with its own command on purpose. They are not readiness
5
+ * (which asks whether a 2026-07-28 client can talk to this server) and not
6
+ * quality (which asks what a model experiences once a tool is in front of it).
7
+ * They ask a third thing: under deferred loading, does anything ever put this
8
+ * tool in front of the model at all.
9
+ *
10
+ * E141 is the one that is not a heuristic. The others read a simulation and
11
+ * are worth less; they are kept because the simulation is the only thing here
12
+ * that models the real mechanism, and because the cases they do catch (a tool
13
+ * with no description, two tools with the same one) are worth catching.
14
+ *
15
+ * None of them feeds `check`'s grade, and `find` produces no grade of its own.
16
+ * ADR-030 has the reasoning.
17
+ */
18
+ function cap(items, n) {
19
+ return { shown: items.slice(0, n), more: Math.max(0, items.length - n) };
20
+ }
21
+ /**
22
+ * A target-supplied value going into a rule message or detail.
23
+ *
24
+ * The reporters strip control characters but keep newlines, because a detail
25
+ * block is legitimately multi-line. That makes a newline inside a tool name a
26
+ * way for the audited server to add its own line to the auditor's output. The
27
+ * values these rules interpolate - names, terms - are all single-line by
28
+ * nature, so they are flattened here, at the point where a multi-line region
29
+ * is built out of them.
30
+ */
31
+ function one(s) {
32
+ return s.replace(/[\r\n]+/g, " ");
33
+ }
34
+ const list = (xs) => xs.map(one).join(", ");
35
+ const e141 = {
36
+ id: "E141",
37
+ title: "tool owns no exclusive vocabulary",
38
+ surface: "find",
39
+ check({ find }) {
40
+ const shared = find.perTool.filter((t) => t.ownTermCount === 0);
41
+ const { shown, more } = cap(shared, 5);
42
+ const findings = shown.map((t) => ({
43
+ ruleId: "E141",
44
+ severity: "warn",
45
+ title: "tool owns no exclusive vocabulary",
46
+ // Stated as what it is: an existence claim about queries, not a
47
+ // prediction about one search. Every term this tool has appears on some
48
+ // other tool, so there is no query that matches it alone. Whether it
49
+ // still wins a shared query depends on term frequencies, which is a much
50
+ // weaker thing to rely on than owning a word.
51
+ message: `tool "${one(t.name)}" has no term that no other tool has, so no search can return it without also returning a competitor`,
52
+ target: t.name,
53
+ // "also appears on", not "covered by": no single tool on this list
54
+ // necessarily carries the whole vocabulary. They are the tools with the
55
+ // most overlap, which is where to look first when writing the fix.
56
+ detail: t.sharedWith.length ? `every word it has also appears on: ${list(t.sharedWith)} (most overlap first)` : undefined,
57
+ fixHint: "put something in the description that only this tool does: the object it acts on, the format it returns, the case it is for",
58
+ }));
59
+ if (more) {
60
+ findings.push({
61
+ ruleId: "E141",
62
+ severity: "warn",
63
+ title: "tool owns no exclusive vocabulary",
64
+ message: `...and ${more} more tools that share every one of their terms with another tool`,
65
+ });
66
+ }
67
+ return findings;
68
+ },
69
+ };
70
+ const e142 = {
71
+ id: "E142",
72
+ title: "a search for the tool's own description does not return it",
73
+ surface: "find",
74
+ check({ find }) {
75
+ const findings = [];
76
+ const unsearchable = find.perTool.filter((t) => t.rank === undefined);
77
+ for (const t of cap(unsearchable, 5).shown) {
78
+ findings.push({
79
+ ruleId: "E142",
80
+ severity: "warn",
81
+ title: "a search for the tool's own description does not return it",
82
+ message: `tool "${one(t.name)}" offers no words to search for: its description is empty or made only of function words, so nothing but the exact name reaches it`,
83
+ target: t.name,
84
+ fixHint: "write a description containing the words someone would use to describe the task this tool does",
85
+ });
86
+ }
87
+ const missed = find.perTool.filter((t) => t.rank !== undefined && !t.reachable);
88
+ for (const t of cap(missed, 5).shown) {
89
+ findings.push({
90
+ ruleId: "E142",
91
+ severity: "warn",
92
+ title: "a search for the tool's own description does not return it",
93
+ message: `tool "${one(t.name)}" ranks ${t.rank} of ${find.toolCount} for a search built from its own description (${list(t.query)}), outside a result window of ${find.method.topK}`,
94
+ target: t.name,
95
+ detail: t.outrankedBy.length ? `outranked by: ${list(t.outrankedBy)}` : undefined,
96
+ fixHint: "make the description carry a term the other tools do not share",
97
+ });
98
+ }
99
+ const more = cap(unsearchable, 5).more + cap(missed, 5).more;
100
+ if (more) {
101
+ findings.push({
102
+ ruleId: "E142",
103
+ severity: "warn",
104
+ title: "a search for the tool's own description does not return it",
105
+ message: `...and ${more} more tools a search for their own description does not return`,
106
+ });
107
+ }
108
+ return findings;
109
+ },
110
+ };
111
+ const e143 = {
112
+ id: "E143",
113
+ title: "tools are indistinguishable to a search",
114
+ surface: "find",
115
+ check({ find }) {
116
+ // Report each pair once. A ties with B is the same fact as B ties with A,
117
+ // and printing both halves doubles the finding count, which would double
118
+ // the apparent size of the problem.
119
+ const seen = new Set();
120
+ const findings = [];
121
+ for (const t of find.perTool) {
122
+ for (const other of t.tiedWith) {
123
+ const key = t.name < other ? `${one(t.name)} ${one(other)}` : `${one(other)} ${one(t.name)}`;
124
+ if (seen.has(key))
125
+ continue;
126
+ seen.add(key);
127
+ if (findings.length >= 5)
128
+ continue;
129
+ findings.push({
130
+ ruleId: "E143",
131
+ severity: "warn",
132
+ title: "tools are indistinguishable to a search",
133
+ message: `tools "${one(t.name)}" and "${one(other)}" score identically for a search built from "${one(t.name)}"'s description (${list(t.query)}); only the alphabetical tie-break separates them`,
134
+ target: t.name,
135
+ fixHint: "give each description a term the other does not have, or merge the tools",
136
+ });
137
+ }
138
+ }
139
+ // The true pair count comes from tiedWithCount, not from the names above.
140
+ // `tiedWith` is capped at three per tool for display, so counting the
141
+ // pairs it yields reported 30 for a catalog whose real answer was 66: a
142
+ // count of what survived a display cap, printed as a count of the problem.
143
+ // Each pair is counted from both ends, hence the halving.
144
+ const totalPairs = Math.round(find.perTool.reduce((n, t) => n + t.tiedWithCount, 0) / 2);
145
+ if (totalPairs > findings.length) {
146
+ findings.push({
147
+ ruleId: "E143",
148
+ severity: "warn",
149
+ title: "tools are indistinguishable to a search",
150
+ message: `...and ${totalPairs - findings.length} more indistinguishable pairs (${totalPairs} in total)`,
151
+ });
152
+ }
153
+ return findings;
154
+ },
155
+ };
156
+ const e146 = {
157
+ id: "E146",
158
+ title: "the only word a tool owns is its own name",
159
+ surface: "find",
160
+ check({ find }) {
161
+ // Names ARE searchable, so this tool is findable in principle and E141
162
+ // correctly does not fire. It is not findable by anyone describing a task,
163
+ // which is the situation deferred loading creates, so the two cases are
164
+ // reported separately rather than merged into one number.
165
+ const nameOnly = find.perTool.filter((t) => t.ownTermCount > 0 && t.ownOutsideNameCount === 0);
166
+ const { shown, more } = cap(nameOnly, 5);
167
+ const findings = shown.map((t) => ({
168
+ ruleId: "E146",
169
+ severity: "info",
170
+ title: "the only word a tool owns is its own name",
171
+ message: t.ownTermCount === 1
172
+ ? `tool "${one(t.name)}" owns one term and it comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`
173
+ : `tool "${one(t.name)}" owns ${t.ownTermCount} terms and every one of them comes from its own name; nothing in its description is unique, so only someone who already knows the name can single it out`,
174
+ target: t.name,
175
+ fixHint: "put a word in the description that no other tool's description uses",
176
+ }));
177
+ if (more) {
178
+ findings.push({
179
+ ruleId: "E146",
180
+ severity: "info",
181
+ title: "the only word a tool owns is its own name",
182
+ message: `...and ${more} more tools whose only exclusive words are their own name`,
183
+ });
184
+ }
185
+ return findings;
186
+ },
187
+ };
188
+ const e144 = {
189
+ id: "E144",
190
+ title: "tool name carries no domain term",
191
+ surface: "find",
192
+ check({ find }) {
193
+ // All-generic AND no name token of its own.
194
+ //
195
+ // "Generic" alone was too weak: the fixture server's `add` is a perfectly
196
+ // descriptive name for an arithmetic tool, and the rule told its author to
197
+ // "put the subject in the name" with no usable suggestion. What separates
198
+ // `add` from `run` is not the word list, it is whether the word belongs to
199
+ // this tool alone in this catalog. `ownTermCount > ownOutsideNameCount`
200
+ // means at least one exclusive term came from the name, which is exactly
201
+ // the case where a broad pattern search does reach it.
202
+ const generic = find.perTool.filter((t) => t.genericName && t.ownTermCount === t.ownOutsideNameCount);
203
+ const { shown, more } = cap(generic, 5);
204
+ const findings = shown.map((t) => ({
205
+ ruleId: "E144",
206
+ severity: "info",
207
+ title: "tool name carries no domain term",
208
+ message: `tool "${one(t.name)}" is named only with generic words, so a broad pattern search (Claude writes things like ".*weather.*") can reach it only through its description`,
209
+ target: t.name,
210
+ fixHint: "put the subject in the name: what does it act on",
211
+ }));
212
+ if (more) {
213
+ findings.push({
214
+ ruleId: "E144",
215
+ severity: "info",
216
+ title: "tool name carries no domain term",
217
+ message: `...and ${more} more tools named only with generic words`,
218
+ });
219
+ }
220
+ return findings;
221
+ },
222
+ };
223
+ const e145 = {
224
+ id: "E145",
225
+ title: "deferred catalog with tools a search cannot single out",
226
+ surface: "find",
227
+ check({ find }) {
228
+ if (!find.deferRecommended)
229
+ return [];
230
+ // The union, not the sum. These two sets overlap heavily - a tool with no
231
+ // exclusive vocabulary is often also one the probe misses - and adding
232
+ // them read as twice as many problems as there are. On a 15-tool server
233
+ // this line once claimed 24.
234
+ const affected = new Set();
235
+ for (const t of find.perTool) {
236
+ if (t.ownTermCount === 0 || !t.reachable)
237
+ affected.add(t.name);
238
+ }
239
+ if (!affected.size)
240
+ return [];
241
+ return [
242
+ {
243
+ ruleId: "E145",
244
+ severity: "warn",
245
+ title: "deferred catalog with tools a search cannot single out",
246
+ // The reason comes from the analysis, not from a repeat of one
247
+ // threshold. Anthropic lists several conditions; hardcoding the token
248
+ // one here printed "~3500 tokens, past the ~10.0k" on a catalog that
249
+ // qualified on tool count instead.
250
+ message: `tool search is recommended for this catalog (${find.deferBecause.map(one).join("; ")}), ` +
251
+ `and ${affected.size} of ${find.toolCount} tool${affected.size === 1 ? " cannot" : "s cannot"} be singled out by a search. ` +
252
+ `Once this catalog is deferred, a tool nothing surfaces costs nothing and does nothing.`,
253
+ fixHint: "fix the descriptions above, or split the server so fewer tools compete in one catalog",
254
+ },
255
+ ];
256
+ },
257
+ };
258
+ export const FIND_RULES = [e141, e142, e143, e144, e145, e146];
259
+ /** E141-E146 are findability rules: reported by `efaimo find`, never part of a `check` grade. */
260
+ export function isFindabilityRuleId(id) {
261
+ return /^E14\d$/.test(id);
262
+ }
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { scanTextForInjection } from "../injection.js";
3
+ import { tokenize, QUERY_STOPWORDS } from "../../find/tokenize.js";
3
4
  import { formatTokens } from "../../util/misc.js";
4
5
  const NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
5
6
  const STANDARD_FIELDS = new Set(["name", "description", "license", "compatibility", "metadata", "allowed-tools"]);
@@ -77,6 +78,14 @@ const s101 = {
77
78
  return findings;
78
79
  },
79
80
  };
81
+ // Grounded on the 38 public skills in research/skills-index/manifest.json,
82
+ // measured 2026-09-03. The shortest real description there is 68 characters
83
+ // (`template-skill`) and the fewest content terms any of them carries is 4,
84
+ // also `template-skill`. Both floors sit below every real description in that
85
+ // corpus and above the filler this rule exists to catch, and both are far
86
+ // under the spec's 1024-character ceiling.
87
+ const S102_MIN_CHARS = 40;
88
+ const S102_MIN_CONTENT_TERMS = 4;
80
89
  const s102 = {
81
90
  id: "S102",
82
91
  title: "weak trigger description",
@@ -87,7 +96,19 @@ const s102 = {
87
96
  return []; // S101 already errors
88
97
  const findings = [];
89
98
  const label = skillLabel(skill);
90
- if (desc.length < 20) {
99
+ // The content this description carries once the words every description
100
+ // contains are removed. A length floor alone was not enough: until
101
+ // 2026-09-03 this rule read `desc.length < 20`, then asked only whether one
102
+ // of four trigger words appeared anywhere, so "Useful for various tasks."
103
+ // scored a clean 100 because "for" happens to sit inside "for various
104
+ // tasks". It tested for the presence of a trigger WORD rather than for the
105
+ // presence of a trigger, which is a keyword check wearing the costume of a
106
+ // semantic one. Counting what survives the stopword list is the same
107
+ // measurement `find` makes on tool catalogs: a description with almost no
108
+ // distinguishing content cannot be matched to a task by any host, whatever
109
+ // words it happens to include.
110
+ const contentTerms = new Set(tokenize(desc).filter((w) => !QUERY_STOPWORDS.has(w)));
111
+ if (desc.length < S102_MIN_CHARS) {
91
112
  findings.push({
92
113
  ruleId: "S102",
93
114
  severity: "warn",
@@ -97,6 +118,16 @@ const s102 = {
97
118
  fixHint: "state what the skill does AND when to use it, with concrete trigger words",
98
119
  });
99
120
  }
121
+ else if (contentTerms.size < S102_MIN_CONTENT_TERMS) {
122
+ findings.push({
123
+ ruleId: "S102",
124
+ severity: "warn",
125
+ title: "weak trigger description",
126
+ message: `description carries only ${contentTerms.size} distinct content ${contentTerms.size === 1 ? "term" : "terms"} once common words are removed; there is nothing here for a host to match a task against`,
127
+ target: label,
128
+ fixHint: "name the situations, artifacts and words a user would actually type",
129
+ });
130
+ }
100
131
  else if (!/\buse\b|\bwhen\b|\bfor\b|\bhelps?\b/i.test(desc)) {
101
132
  findings.push({
102
133
  ruleId: "S102",