Files
gitmost/packages/mcp/test/unit/tool-inventory.test.mjs
T
agent_coder d0f99052cf refactor(tools): генерировать инвентарь SERVER_INSTRUCTIONS из реестра + guard имён тулов в промпте (#448)
Финальный линк Фазы 1б. Инвентарь тулов жил в 4 рукописных прозаических копиях
(SERVER_INSTRUCTIONS под regex-тестом; <tool_catalog>; имена в ai-chat.prompt.ts
без гарда; README) — роадмап #416 планировал 4 последовательных ручных правки
этого текста (#411/#412/#413/#415).

- SERVER_INSTRUCTIONS разбит (новый модуль server-instructions.ts): ROUTING_
  PROSE (рукописные intent-подсказки «когда что» — осмысленно ручные, перенесены
  ДОСЛОВНО со всеми предостережениями: <=250 у create_comment, soft-delete у
  delete_page, baseHash у drawio_update, PUBLIC у share_page) + buildToolInventory()
  — генерирует <tool_inventory> из реестра (mcpName + purpose из catalogLine,
  группировка по TOOL_FAMILY, бакет OTHER ловит незамаппленное → тул нельзя
  тихо потерять) + 5 inline MCP-only (INLINE_MCP_INVENTORY). Детерминирован
  (семейства FAMILY_ORDER, имена localeCompare). regex-тест server-instructions
  удалён; структурные гарантии — в новом tool-inventory.test.mjs (точное
  членство множества сильнее старого \b-скрейпа).
- Имена тулов в ai-chat.prompt.ts → через экспорт PROMPT_TOOL_NAMES; новый гард
  ai-chat.prompt.tool-names.spec.ts: каждое имя — реальный тул реестра, скан
  guidance-нот на camelCase-токены падает на несуществующем (escape-
  нейтрализация против ложных nThe-токенов).
- INLINE_TOOL_TIERS уже содержал ровно 8 genuinely-inline тулов (после #445) —
  сжатие не потребовалось.

Критерий: добавление/переименование спека меняет инвентарь БЕЗ правки прозы.
Внутреннее ревью: APPROVE — фактическим прогоном подтверждено, что НИ ОДИН тул
из старого SERVER_INSTRUCTIONS не выпал (диф старый-vs-новый пуст; добавился
get_workspace, раньше прятавшийся в EXCEPTIONS); проза дословна; инвентарь
полон/детерминирован/без фантомов; гард краснеет на обеих ветках провала.
613 node + 289 jest зелёные. Стоит на #445 — мержить последним в стопке 1б.

README-каталоги вне обязательного скоупа (docs-скрипт) — в чек-лист #412.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 09:21:28 +03:00

105 lines
4.1 KiB
JavaScript

// Guard: the GENERATED <tool_inventory> in SERVER_INSTRUCTIONS (issue #448)
// names every tool the server registers. The inventory is BUILT from the
// registry (SHARED_TOOL_SPECS' mcpName/catalogLine + INLINE_MCP_INVENTORY), so
// the shared-registry tools can never drift by construction; this test's job is
// to catch the ONE remaining manual list — INLINE_MCP_INVENTORY — falling out
// of sync with the inline `server.registerTool(...)` calls in index.ts.
//
// It also asserts the composed guide keeps its routing prose (the hand-written
// intent hints) and is a valid non-empty string — the structural guarantees the
// old name-scraper test (server-instructions.test.mjs, now deleted) carried,
// minus its now-redundant per-name prose scrape.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
SERVER_INSTRUCTIONS,
ROUTING_PROSE,
buildToolInventoryLines,
} from "../../build/server-instructions.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, "..", "..", "src");
/**
* Every tool name the MCP server registers, scraped from the SOURCE:
* - inline `server.registerTool("name", ...)` calls in index.ts;
* - shared specs in tool-specs.ts (`mcpName: 'name'`).
* Same two registration mechanisms the old guard covered.
*/
function registeredToolNames() {
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
const names = new Set();
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
names.add(m[1]);
}
for (const m of specsSrc.matchAll(/mcpName:\s*['"]([a-z0-9_]+)['"]/g)) {
names.add(m[1]);
}
return names;
}
test("the generated inventory names every registered tool", () => {
const registered = registeredToolNames();
// Sanity: if the scrape regressed (regex drift), fail loudly rather than
// vacuously passing on an empty set.
assert.ok(
registered.size >= 40,
`sanity: expected 40+ registered tools, got ${registered.size} — ` +
"the extraction regexes in this test likely drifted from the source",
);
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
const missing = [...registered].filter((n) => !inventory.has(n)).sort();
assert.deepEqual(
missing,
[],
`tools missing from the generated <tool_inventory>: ${missing.join(", ")} — ` +
"a SHARED spec is covered automatically; an INLINE MCP-only tool needs a " +
"line added to INLINE_MCP_INVENTORY in src/server-instructions.ts",
);
});
test("the inventory has no phantom tool (every line is a real registered tool)", () => {
const registered = registeredToolNames();
const phantom = buildToolInventoryLines()
.map((l) => l.name)
.filter((n) => !registered.has(n))
.sort();
assert.deepEqual(
phantom,
[],
`<tool_inventory> lists tools that are NOT registered: ${phantom.join(", ")}`,
);
});
test("every inventory line has a non-empty purpose", () => {
for (const line of buildToolInventoryLines()) {
assert.equal(typeof line.purpose, "string");
assert.ok(line.purpose.trim().length > 0, `${line.name}: empty purpose`);
}
});
test("SERVER_INSTRUCTIONS keeps the routing prose and the generated inventory", () => {
assert.equal(typeof SERVER_INSTRUCTIONS, "string");
assert.ok(SERVER_INSTRUCTIONS.length > 0, "SERVER_INSTRUCTIONS is empty");
// Routing prose is spliced in verbatim (the hand-written intent hints).
assert.ok(
SERVER_INSTRUCTIONS.startsWith(ROUTING_PROSE),
"the routing prose is not preserved at the head of the guide",
);
// The generated inventory block is present.
assert.match(SERVER_INSTRUCTIONS, /<tool_inventory>/);
assert.match(SERVER_INSTRUCTIONS, /<\/tool_inventory>/);
// The routing families are still present in the prose.
for (const family of ["READ:", "EDIT:", "PAGES:", "COMMENTS:", "HISTORY:"]) {
assert.ok(
SERVER_INSTRUCTIONS.includes(family),
`routing prose lost its ${family} section`,
);
}
});