efaimo 0.1.1 → 0.2.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 (50) hide show
  1. package/CHANGELOG.md +128 -5
  2. package/README.md +69 -7
  3. package/SKILL.md +16 -9
  4. package/dist/check/check.js +8 -0
  5. package/dist/cli.js +179 -27
  6. package/dist/clients/introspect.js +10 -2
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +121 -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 +120 -0
  13. package/dist/find/find.js +403 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +6 -3
  17. package/dist/index.js +13 -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 +12 -1
  23. package/dist/reporters/pretty.js +177 -10
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/injection.d.ts +1 -2
  27. package/dist/rules/injection.js +0 -11
  28. package/dist/rules/mcp/index.js +21 -1
  29. package/dist/rules/skill/index.js +5 -3
  30. package/dist/rules/version.d.ts +26 -0
  31. package/dist/rules/version.js +26 -0
  32. package/dist/targets/repoScan.js +0 -0
  33. package/dist/testing/anthropicRunner.d.ts +30 -0
  34. package/dist/testing/anthropicRunner.js +43 -8
  35. package/dist/testing/harness.d.ts +33 -1
  36. package/dist/testing/harness.js +66 -7
  37. package/dist/testing/openaiRunner.js +15 -8
  38. package/dist/testing/stats.d.ts +16 -2
  39. package/dist/testing/stats.js +26 -2
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/weigh/anthropicExact.d.ts +12 -1
  43. package/dist/weigh/anthropicExact.js +9 -1
  44. package/dist/weigh/weigh.js +10 -3
  45. package/docs/INTEGRATIONS.md +15 -1
  46. package/docs/METHODOLOGY.md +111 -7
  47. package/docs/RULES.md +42 -5
  48. package/examples/scenario.crm.yaml +8 -0
  49. package/examples/scenario.example.yaml +8 -0
  50. package/package.json +2 -2
@@ -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,12 +124,14 @@ 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)}%)`,
105
131
  ` delta ${paint(pc.bold, `${sign}${r.deltaPoints} points`)} ${paint(verdictColor, r.verdict)}`,
106
- ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`,
132
+ Number.isFinite(r.ci.lo)
133
+ ? ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`
134
+ : ` significance not computable: an arm produced no scoreable trial`,
107
135
  "",
108
136
  ];
109
137
  for (const n of r.notes)
@@ -128,21 +156,21 @@ export function renderSkillSetPretty(res) {
128
156
  for (const s of [...res.perSkill].sort((a, b) => a.report.grade.score - b.report.grade.score)) {
129
157
  const g = s.report.grade;
130
158
  const c = s.report.counts;
131
- 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)} ` +
132
160
  `${paint(pc.red, String(c.error))}e ${paint(pc.yellow, String(c.warn))}w ${paint(pc.cyan, String(c.info))}i`);
133
161
  }
134
162
  if (res.setFindings.length) {
135
163
  lines.push("");
136
164
  lines.push(paint(pc.bold, "across the set"));
137
165
  for (const f of sortFindings(res.setFindings)) {
138
- 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)}`);
139
167
  }
140
168
  }
141
169
  const flagged = res.perSkill.filter((s) => s.report.findings.length);
142
170
  if (flagged.length) {
143
171
  lines.push("");
144
172
  for (const s of flagged) {
145
- 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)}`));
146
174
  for (const f of s.report.findings) {
147
175
  lines.push(` ${sevGlyph(f)} ${paint(pc.bold, f.ruleId)} ${safeText(f.message)}`);
148
176
  if (f.fixHint)
@@ -168,7 +196,7 @@ export function renderServerWeighPretty(w) {
168
196
  if (w.instructionsTokens > 0)
169
197
  lines.push(` server instructions ${n(w.instructionsTokens).padStart(4)}`);
170
198
  if (w.anthropicExactTotal !== undefined) {
171
- 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")})`);
172
200
  }
173
201
  if (w.perTool.length) {
174
202
  lines.push("");
@@ -181,9 +209,9 @@ export function renderServerWeighPretty(w) {
181
209
  // column in its own flagship demo. Capped so one pathological name cannot
182
210
  // push the numbers off a terminal.
183
211
  const shown = w.perTool.slice(0, 8);
184
- 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)));
185
213
  for (const [i, t] of shown.entries()) {
186
- 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)}`);
187
215
  }
188
216
  if (w.perTool.length > 8)
189
217
  lines.push(paint(pc.dim, ` (+${w.perTool.length - 8} more)`));
@@ -203,7 +231,7 @@ export function renderSkillWeighPretty(w) {
203
231
  lines.push("");
204
232
  lines.push(` ${"skill".padEnd(28)} ${"metadata".padStart(8)} ${"body".padStart(9)} ${"lines".padStart(6)} refs`);
205
233
  for (const s of w.perSkill) {
206
- 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)}` : "-"}`);
207
235
  }
208
236
  lines.push("");
209
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)`);
@@ -212,6 +240,145 @@ export function renderSkillWeighPretty(w) {
212
240
  lines.push(paint(pc.dim, `note: ${safeText(note)}`));
213
241
  return lines.join("\n");
214
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
+ lines.push(paint(pc.dim, ` words ${"tool".padEnd(nameW)} vocabulary no other tool has`));
294
+ for (const t of shown) {
295
+ const count = t.ownTermCount === 0
296
+ ? paint(pc.red, " 0")
297
+ : t.ownTermCount === 1
298
+ ? paint(pc.yellow, " 1")
299
+ : paint(pc.green, String(t.ownTermCount).padStart(5));
300
+ let tail;
301
+ if (t.ownTermCount === 0) {
302
+ tail = paint(pc.red, t.sharedWith.length ? `none: every word also on ${cell(t.sharedWith.join(", "))}` : "none");
303
+ }
304
+ else {
305
+ const more = t.ownTermCount - t.ownTerms.length;
306
+ tail = paint(pc.dim, cell(t.ownTerms.join(", ")) + (more > 0 ? ` (+${more})` : ""));
307
+ }
308
+ // The probe only ever appears as a flag on a row it failed. A column of
309
+ // "1" down the whole table would be a number that never varies presented
310
+ // as if it were a measurement.
311
+ const probe = t.rank === undefined
312
+ ? paint(pc.red, " [probe: no description]")
313
+ : t.reachable
314
+ ? ""
315
+ : paint(pc.red, ` [probe: rank ${t.rank}]`);
316
+ lines.push(` ${count} ${cell(t.name).padEnd(nameW)} ${tail}${probe}`);
317
+ }
318
+ // Describe the hidden rows from the hidden rows, never from an assumption.
319
+ // This line used to read "(+N more, each owning at least one word)" without
320
+ // checking: on a catalog where all 18 tools owned nothing it printed that
321
+ // three lines under a `0/18` headline saying the opposite.
322
+ const hiddenRows = byWorst.slice(shown.length);
323
+ if (hiddenRows.length) {
324
+ const hiddenAtZero = hiddenRows.filter((t) => t.ownTermCount === 0).length;
325
+ lines.push(paint(pc.dim, ` (+${hiddenRows.length} more` +
326
+ (hiddenAtZero ? `, ${hiddenAtZero} of them owning no word` : ", each owning at least one word") +
327
+ `; --json for all of them)`));
328
+ }
329
+ return lines.join("\n");
330
+ }
331
+ /** The findings half of `efaimo find`, printed under the table. */
332
+ export function renderFindFindingsPretty(f, findings) {
333
+ const lines = [""];
334
+ if (!findings.length) {
335
+ lines.push(paint(pc.green, " no findability findings."));
336
+ }
337
+ else {
338
+ for (const x of sortFindings(findings))
339
+ renderFinding(lines, x);
340
+ }
341
+ lines.push("");
342
+ for (const note of f.notes) {
343
+ // The two vacuity notes are already printed inline, in yellow, on the row
344
+ // the reader is looking at. Repeating them here as prose was the same
345
+ // warning twice in one screen; the data still carries them for --json and
346
+ // --md, where there is no inline row to carry it.
347
+ if (note.startsWith("VACUOUS"))
348
+ continue;
349
+ for (const [i, line] of wrapNote(safeText(note)).entries()) {
350
+ lines.push(paint(pc.dim, i === 0 ? `note: ${line}` : ` ${line}`));
351
+ }
352
+ }
353
+ // What to do next. A clean run used to end on "no findability findings" and
354
+ // a rules URL, which tells a first-time reader nothing about how to keep it
355
+ // that way. `distinct` is named because it is the number that can fail at
356
+ // any catalog size; the probe cannot.
357
+ if (!f.distinctVacuous) {
358
+ lines.push(paint(pc.dim, `gate it: efaimo find "<server>" --min-distinct ${Math.floor(f.distinct.pct)}` +
359
+ (f.distinct.pct < 100 ? " (raise it as you fix the tools above)" : "")));
360
+ }
361
+ lines.push(paint(pc.dim, `rules: ${DOCS_RULES}`));
362
+ return lines.join("\n");
363
+ }
364
+ /** Soft-wrap a long note at ~92 columns on word boundaries, so terminals do not ragged-wrap mid-number. */
365
+ function wrapNote(s, width = 92) {
366
+ const out = [];
367
+ for (const paragraph of s.split("\n")) {
368
+ let line = "";
369
+ for (const word of paragraph.split(/\s+/)) {
370
+ if (line && line.length + 1 + word.length > width) {
371
+ out.push(line);
372
+ line = word;
373
+ }
374
+ else {
375
+ line = line ? `${line} ${word}` : word;
376
+ }
377
+ }
378
+ out.push(line);
379
+ }
380
+ return out;
381
+ }
215
382
  export function renderDiffPretty(d, opts) {
216
383
  const lines = [];
217
384
  const sign = d.delta >= 0 ? "+" : "";
@@ -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,8 +1,7 @@
1
- import type { Finding, Severity } from "../core/types.js";
1
+ import type { Finding } from "../core/types.js";
2
2
  interface InjectionPattern {
3
3
  key: string;
4
4
  re: RegExp;
5
- severity: Severity;
6
5
  label: string;
7
6
  }
8
7
  /**
@@ -9,19 +9,16 @@ export const INJECTION_PATTERNS = [
9
9
  {
10
10
  key: "override-instructions",
11
11
  re: /ignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions|messages|rules|prompts)/i,
12
- severity: "error",
13
12
  label: "attempts to override prior instructions",
14
13
  },
15
14
  {
16
15
  key: "hide-from-user",
17
16
  re: /do\s+not\s+(?:tell|reveal|mention|inform|show|disclose)\b[\s\S]{0,40}\b(?:user|human|operator)/i,
18
- severity: "error",
19
17
  label: "instructs the agent to hide behavior from the user",
20
18
  },
21
19
  {
22
20
  key: "act-without-consent",
23
21
  re: /without\s+(?:telling|asking|notifying|informing)\s+(?:the\s+)?(?:user|human)/i,
24
- severity: "error",
25
22
  label: "instructs the agent to act without user consent",
26
23
  },
27
24
  {
@@ -29,51 +26,43 @@ export const INJECTION_PATTERNS = [
29
26
  // "post" and "token" dropped: they match API documentation ("Token counting
30
27
  // POST /v1/...") far more than exfiltration.
31
28
  re: /\b(?:api[_-]?key|secret|password|credential)s?\b[\s\S]{0,40}\b(?:send|forward|exfiltrat|upload|leak|transmit|include\s+it)\b/i,
32
- severity: "error",
33
29
  label: "references sending credentials or secrets somewhere",
34
30
  },
35
31
  {
36
32
  key: "sensitive-file-read",
37
33
  re: /\b(?:read|open|cat|print|upload)\b[\s\S]{0,40}(?:\.env\b|\.ssh\b|id_rsa|credentials|\.aws\b|\.npmrc\b)/i,
38
- severity: "error",
39
34
  label: "references reading sensitive local files",
40
35
  },
41
36
  {
42
37
  key: "disable-safety",
43
38
  re: /\b(?:disable|bypass|ignore|remove)\s+(?:safety|guardrails?|filters?|restrictions?|policy|policies)\b/i,
44
- severity: "error",
45
39
  label: "asks to bypass safety or policy",
46
40
  },
47
41
  {
48
42
  key: "cross-tool-steering",
49
43
  re: /before\s+(?:using|calling|running)\b[^.?!]{0,40}?\btools?\b[\s,]{1,4}(?:always\s+|first\s+|please\s+)?(?:call|run|use|fetch|invoke)\b/i,
50
- severity: "warn",
51
44
  label: "steers the agent to call another tool first (cross-tool steering)",
52
45
  },
53
46
  {
54
47
  key: "templated-exfil-url",
55
48
  // Bounded runs (no adjacent unbounded same-class quantifiers) to avoid ReDoS.
56
49
  re: /https?:\/\/[^\s"'<>]{1,150}[?&][^\s"'<>]{0,150}(?:\$\{[^}]{1,60}\}|\{\{[^}]{1,60}\}\}|\{[^}]{1,60}\})/,
57
- severity: "warn",
58
50
  label: "URL with a templated query parameter (possible exfiltration channel)",
59
51
  },
60
52
  {
61
53
  key: "zero-width-chars",
62
54
  // eslint-disable-next-line no-control-regex
63
55
  re: /[​‌‍⁠]/,
64
- severity: "warn",
65
56
  label: "zero-width or invisible characters present",
66
57
  },
67
58
  {
68
59
  key: "hidden-html-comment",
69
60
  re: /<!--[^>]{0,240}\b(?:must|always|never|call|run|fetch|send|ignore)\b[^>]{0,240}-->/i,
70
- severity: "warn",
71
61
  label: "HTML comment containing imperative instructions (hidden from rendered view)",
72
62
  },
73
63
  {
74
64
  key: "persona-override",
75
65
  re: /\byou\s+are\s+now\b|\bpretend\s+to\s+be\b|\bnew\s+system\s+prompt\b/i,
76
- severity: "warn",
77
66
  label: "persona or system-prompt override language",
78
67
  },
79
68
  ];
@@ -166,6 +166,23 @@ const e105 = {
166
166
  },
167
167
  ];
168
168
  }
169
+ if (bare.kind === "exit") {
170
+ // A crash on the bare request is not a timeout and not a clean answer: the
171
+ // server did not serve a stateless tools/list, so a 2026-07-28 client
172
+ // cannot talk to it. Left unhandled, this outcome produced NO finding, so
173
+ // a crashing server looked one item MORE migrated than one that answers
174
+ // with a not-initialized error.
175
+ return [
176
+ {
177
+ ruleId: "E105",
178
+ severity: "warn",
179
+ title: "requires the removed initialize handshake",
180
+ message: `the server process exited on a bare stateless tools/list (${bare.errorMessage ?? "no reply"}), so it does not answer requests without a prior initialize`,
181
+ detail: `${SPEC} removes initialize and sessions; a server that exits on a bare request cannot serve a ${SPEC} client. A server that answers, even with an error, is judged by E106/E107/E118 instead.`,
182
+ fixHint: "upgrade to a 2.x SDK, or answer requests without a prior initialize instead of exiting",
183
+ },
184
+ ];
185
+ }
169
186
  if (bare.kind === "timeout") {
170
187
  return [
171
188
  {
@@ -526,7 +543,10 @@ const e124 = {
526
543
  }
527
544
  }
528
545
  if (totalParams > 5 && undocumented / totalParams > 0.3) {
529
- findings.push({
546
+ // Prepend, not append: capList keeps the first 5, and on the worst schemas
547
+ // (5+ oversized enums) an appended summary was exactly the line that got
548
+ // dropped. The aggregate is the headline, so it leads.
549
+ findings.unshift({
530
550
  ruleId: "E124",
531
551
  severity: "warn",
532
552
  title: "schema issues",