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.
- package/CHANGELOG.md +160 -1
- package/README.md +75 -6
- package/SKILL.md +14 -7
- package/dist/check/diff.d.ts +124 -0
- package/dist/check/diff.js +165 -0
- package/dist/cli.js +237 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +144 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +122 -0
- package/dist/find/find.js +405 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +14 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +25 -1
- package/dist/reporters/pretty.js +228 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/skill/index.js +32 -1
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +32 -1
- package/dist/testing/harness.js +28 -3
- package/dist/testing/stats.d.ts +16 -0
- package/dist/testing/stats.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +110 -6
- package/docs/RULES.md +40 -3
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +3 -1
package/dist/reporters/pretty.js
CHANGED
|
@@ -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)} ${
|
|
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)})`)} ${
|
|
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)} ${
|
|
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, `${
|
|
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
|
|
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) =>
|
|
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)}. ${
|
|
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(` ${
|
|
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,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
|
-
|
|
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",
|