a36b6b5959
Коммит 2. Каждое ручное зеркало получает настоящий гард/деривацию/parity-тест вместо комментария «mirror this»: - ROUTING_PROSE → ОБРАТНЫЙ гард (server-instructions.ts): прямой уже покрыт генерируемым <tool_inventory> (каждый зарегистрированный тул в списке); теперь `unregisteredProseToolMentions` краснеет, если проза ссылается на несуществующий/переименованный тул (camelCase-токены прозы ⊆ реестр, минус явный список не-тул-терминов PROSE_NON_TOOL_TERMS). Раньше мёртвая ссылка в прозе не краснела. Мутационный тест: `getPageContentz` ловится. - LABELS экспорта чата (chat-markdown.util.ts) → parity-тест: каждый ключ-ярлык обязан быть реальным in-app тулом (иначе переименованный тул молча сваливается на generic «Ran tool <name>»), и оба языка (en/ru) размечают ОДИН набор тулов. - зонд comment-signal ×2 (оба хоста) → общий `createListCommentsProbe` в packages/mcp: index.ts и ai-chat-tools.service.ts (через loader) строят tracker.probe из ОДНОЙ фабрики — тела больше не могут разойтись (например, один считает resolved-комментарии, другой нет). Проброшен через loader-границу как опциональный (отсутствует на устаревшем билде → сигнал выключен). - countAnchorMatches (comment-anchor.ts) → делегирует решение exact-wins/strip-fallback единственному резолверу resolveAnchorSelection вместо параллельной копии; поведение идентично (rawCanAnchor ⟺ rawCount>0), parity-тест по корпусу краснеет при расхождении count↔resolve. - normalize+sha256 ×2 (gen-registry-stamp.mjs + docmost-client.loader.ts): зеркало УЖЕ закрыто cross-impl parity-тестом (CROSS_IMPL_TREE/EXPECTED проверяется с обеих сторон) — критерий issue «либо parity-тест» уже выполнен; извлечение общего модуля через границу пакета/билд-шага регрессионно-опасно для load-bearing integrity-проверки (#486), поэтому оставлено как есть. Тесты: mcp node --test unit+mock зелёные (844); затронутые server-specs (chat-markdown, comment-signal-inapp, loader, service, tiers, contract, cap) зелёные (351). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
7.3 KiB
JavaScript
178 lines
7.3 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,
|
|
registeredMcpToolNames,
|
|
unregisteredProseToolMentions,
|
|
PROSE_NON_TOOL_TERMS,
|
|
} 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'`), EXCEPT `inAppOnly`
|
|
* specs, which the registry loop in index.ts SKIPS on the MCP host (#411).
|
|
* 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-zA-Z0-9_]+)"/g)) {
|
|
names.add(m[1]);
|
|
}
|
|
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
|
|
// carries `inAppOnly: true` (not registered on the external MCP host).
|
|
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
|
|
const nameMatch = block.match(/mcpName:\s*['"]([a-zA-Z0-9_]+)['"]/);
|
|
if (!nameMatch) continue;
|
|
if (/inAppOnly:\s*true/.test(block)) continue;
|
|
names.add(nameMatch[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(", ")}`,
|
|
);
|
|
});
|
|
|
|
// #411: the external MCP surface gains updatePageMarkdown and LOSES
|
|
// importPageMarkdown (now inAppOnly). The in-app agent still keeps
|
|
// importPageMarkdown — asserted in the server-side contract spec. (#412 renamed
|
|
// both public MCP tool names to camelCase.)
|
|
test("updatePageMarkdown is on the MCP surface; importPageMarkdown is NOT", () => {
|
|
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
|
|
assert.ok(
|
|
inventory.has("updatePageMarkdown"),
|
|
"updatePageMarkdown should be registered on the external MCP surface",
|
|
);
|
|
assert.ok(
|
|
!inventory.has("importPageMarkdown"),
|
|
"importPageMarkdown must be dropped from the external MCP surface (#411)",
|
|
);
|
|
// And the routing prose no longer points MCP clients at it.
|
|
assert.ok(
|
|
!ROUTING_PROSE.includes("importPageMarkdown"),
|
|
"ROUTING_PROSE still mentions the removed importPageMarkdown",
|
|
);
|
|
assert.ok(
|
|
ROUTING_PROSE.includes("updatePageMarkdown"),
|
|
"ROUTING_PROSE should mention updatePageMarkdown",
|
|
);
|
|
});
|
|
|
|
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`,
|
|
);
|
|
}
|
|
});
|
|
|
|
// #494 — REVERSE drift-guard: every camelCase tool reference in the routing prose
|
|
// must be a tool the MCP host actually registers. The forward direction (every
|
|
// registered tool is listed) is guarded by the generated inventory above; this
|
|
// closes the reverse, where the prose could previously name a nonexistent/renamed
|
|
// tool with nothing reddening.
|
|
test("#494: ROUTING_PROSE names no unregistered tool", () => {
|
|
const dangling = unregisteredProseToolMentions();
|
|
assert.deepEqual(
|
|
dangling,
|
|
[],
|
|
`routing prose references unregistered tool(s): ${dangling.join(", ")} — ` +
|
|
`rename/remove the reference, or add a genuine non-tool term to PROSE_NON_TOOL_TERMS`,
|
|
);
|
|
});
|
|
|
|
test("#494: the reverse guard REDDENS on a dead tool reference (mutation check)", () => {
|
|
// A prose that mentions a plausible-looking but nonexistent camelCase tool must
|
|
// be flagged — proving the guard is not vacuous.
|
|
const prose = "EDIT: rewrite a block -> getPageContentz (renamed away).";
|
|
assert.deepEqual(unregisteredProseToolMentions(prose), ["getPageContentz"]);
|
|
// A real registered tool in the same shape is NOT flagged.
|
|
assert.deepEqual(
|
|
unregisteredProseToolMentions("use getPageJson to read the raw tree"),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test("#494: PROSE_NON_TOOL_TERMS holds no actually-registered tool name", () => {
|
|
// A term parked in the allowlist that is really a registered tool would MASK a
|
|
// dead reference to that tool — keep the two disjoint.
|
|
const registered = registeredMcpToolNames();
|
|
for (const term of PROSE_NON_TOOL_TERMS) {
|
|
assert.ok(
|
|
!registered.has(term),
|
|
`${term} is a registered tool and must not be in PROSE_NON_TOOL_TERMS`,
|
|
);
|
|
}
|
|
});
|