7cb3199d09
Один логический тул жил под двумя именами: внешний MCP snake_case (edit_page_text), in-app camelCase (editPageText) — дублирование доков, путаница при переносе промптов/скиллов, помеха шарингу спек (#294). Решение владельца: единый camelCase везде, включая внешний MCP. После этого mcpName === inAppKey. - tool-specs.ts: mcpName ВЫВЕДЕН из ключа спеки (mcpName == inAppKey) для всех 43 shared-спек — раньше divergent snake, теперь равен ключу (проверено: mcpName читается только структурно — цикл регистрации, генератор <tool_inventory>, TOOL_FAMILY). +5 inline-регистраций (tableGet/updateComment/deleteComment/ docmostTransform; search без изменений). Рантайм: 47 тулов, все camelCase, ноль подчёркиваний. - Контракт-конвенция ИНВЕРТИРОВАНА: shared-tool-specs.contract.spec `mcpName === toSnake(inAppKey)` → `mcpName === inAppKey`; tool-specs.test и tool-inventory.test обновлены. - ROUTING_PROSE/TOOL_FAMILY/INLINE_MCP_INVENTORY (server-instructions.ts) → camelCase (105 замен). ai-chat.prompt/guard уже на in-app camelCase-ключах — без изменений (guard прошёл). comment-signal EXCLUDED_TOOLS схлопнут с дублей snake+camel до camelCase. - Некоторое неочевидное: assertUnambiguousMatch(op: "patch_node"|"delete_node") в prosemirror-markdown/node-ops — op интерполируется в model-facing ошибку; литерал-юнион + call-sites → "patchNode"|"deleteNode". - Все snake-имена в описаниях/error-строках/комментах/тестах/доках → camelCase (whole-token, longest-match-first). CHANGELOG: BREAKING-таблица 46 строк + миграция (allowlists mcp__gitmost-*__get_node→__getNode, промпты/скиллы, .mcp.json, метрики по tool-label); релизится вместе с #411. Внутренние имена методов (PageService.updatePageContent и т.п.) НЕ тронуты — переименованы только ИМЕНА ТУЛОВ. Гейт: mcp node --test 677/677; tsc -p apps/server чисто; jest ai-chat-tools. service + shared-tool-specs.contract + tool-tiers + ai-chat.prompt + comment-signal-inapp → 323. Второй линк breaking-окна (#411→ЭТОТ→#413→#415). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
520 lines
16 KiB
JavaScript
520 lines
16 KiB
JavaScript
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
blockPlainText,
|
|
replaceNodeById,
|
|
deleteNodeById,
|
|
assertUnambiguousMatch,
|
|
insertNodeRelative,
|
|
} from "@docmost/prosemirror-markdown";
|
|
|
|
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
|
const textNode = (text) => ({ type: "text", text });
|
|
const para = (id, ...children) => ({
|
|
type: "paragraph",
|
|
attrs: { id },
|
|
content: children,
|
|
});
|
|
const doc = (...children) => ({ type: "doc", content: children });
|
|
const snapshot = (v) => JSON.parse(JSON.stringify(v));
|
|
|
|
// A callout / table-cell wraps its children in `content`, just like any other
|
|
// block, so recursion reaches a paragraph nested inside it.
|
|
const callout = (id, ...children) => ({
|
|
type: "callout",
|
|
attrs: { id, type: "info" },
|
|
content: children,
|
|
});
|
|
const tableDoc = (innerPara) =>
|
|
doc({
|
|
type: "table",
|
|
attrs: { id: "table-1" },
|
|
content: [
|
|
{
|
|
type: "tableRow",
|
|
attrs: { id: "row-1" },
|
|
content: [
|
|
{
|
|
type: "tableCell",
|
|
attrs: { id: "cell-1" },
|
|
content: [innerPara],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// blockPlainText
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("blockPlainText concatenates nested text", () => {
|
|
const node = {
|
|
type: "callout",
|
|
content: [
|
|
para("p-1", textNode("Hello "), textNode("world")),
|
|
para("p-2", textNode("!")),
|
|
],
|
|
};
|
|
assert.equal(blockPlainText(node), "Hello world!");
|
|
});
|
|
|
|
test("blockPlainText returns '' for nullish / non-object", () => {
|
|
assert.equal(blockPlainText(null), "");
|
|
assert.equal(blockPlainText(undefined), "");
|
|
assert.equal(blockPlainText("just a string"), "");
|
|
});
|
|
|
|
test("blockPlainText reads a bare text node", () => {
|
|
assert.equal(blockPlainText(textNode("solo")), "solo");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// replaceNodeById
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("replaceNodeById replaces the matching block and leaves others, count===1", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("one")),
|
|
para("p-2", textNode("two")),
|
|
para("p-3", textNode("three")),
|
|
);
|
|
const newNode = para("p-2", textNode("REPLACED"));
|
|
|
|
const { doc: out, replaced } = replaceNodeById(input, "p-2", newNode);
|
|
|
|
assert.equal(replaced, 1);
|
|
// Target replaced.
|
|
assert.equal(out.content[1].content[0].text, "REPLACED");
|
|
// Siblings untouched (text and ids).
|
|
assert.equal(out.content[0].content[0].text, "one");
|
|
assert.equal(out.content[2].content[0].text, "three");
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "p-2", "p-3"],
|
|
);
|
|
});
|
|
|
|
test("replaceNodeById on no-match returns replaced===0 and does not throw", () => {
|
|
const input = doc(para("p-1", textNode("one")));
|
|
const { doc: out, replaced } = replaceNodeById(
|
|
input,
|
|
"missing",
|
|
para("x", textNode("x")),
|
|
);
|
|
assert.equal(replaced, 0);
|
|
// Document content is preserved.
|
|
assert.equal(out.content[0].content[0].text, "one");
|
|
});
|
|
|
|
test("replaceNodeById replaces EVERY node sharing the id (count reflects all)", () => {
|
|
const input = doc(
|
|
para("dup", textNode("a")),
|
|
para("dup", textNode("b")),
|
|
para("keep", textNode("c")),
|
|
);
|
|
const { doc: out, replaced } = replaceNodeById(
|
|
input,
|
|
"dup",
|
|
para("dup", textNode("NEW")),
|
|
);
|
|
assert.equal(replaced, 2);
|
|
assert.equal(out.content[0].content[0].text, "NEW");
|
|
assert.equal(out.content[1].content[0].text, "NEW");
|
|
assert.equal(out.content[2].content[0].text, "c");
|
|
// The two replacements must not share a reference (deep clone per match).
|
|
assert.notEqual(out.content[0], out.content[1]);
|
|
});
|
|
|
|
test("replaceNodeById reaches a node nested inside a callout", () => {
|
|
const input = doc(callout("c-1", para("inner", textNode("old"))));
|
|
const { doc: out, replaced } = replaceNodeById(
|
|
input,
|
|
"inner",
|
|
para("inner", textNode("new")),
|
|
);
|
|
assert.equal(replaced, 1);
|
|
assert.equal(out.content[0].content[0].content[0].text, "new");
|
|
});
|
|
|
|
test("replaceNodeById reaches a node nested inside a table cell", () => {
|
|
const input = tableDoc(para("deep", textNode("before")));
|
|
const { doc: out, replaced } = replaceNodeById(
|
|
input,
|
|
"deep",
|
|
para("deep", textNode("after")),
|
|
);
|
|
assert.equal(replaced, 1);
|
|
const cellPara = out.content[0].content[0].content[0].content[0];
|
|
assert.equal(cellPara.content[0].text, "after");
|
|
});
|
|
|
|
test("replaceNodeById does NOT mutate input (deep-equal snapshot)", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("one")),
|
|
callout("c-1", para("inner", textNode("old"))),
|
|
);
|
|
const snap = snapshot(input);
|
|
const { doc: out } = replaceNodeById(
|
|
input,
|
|
"inner",
|
|
para("inner", textNode("changed")),
|
|
);
|
|
assert.deepEqual(input, snap);
|
|
assert.notEqual(out, input);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// deleteNodeById
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("deleteNodeById removes the block and reports deleted===1", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("one")),
|
|
para("p-2", textNode("two")),
|
|
para("p-3", textNode("three")),
|
|
);
|
|
const { doc: out, deleted } = deleteNodeById(input, "p-2");
|
|
assert.equal(deleted, 1);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "p-3"],
|
|
);
|
|
});
|
|
|
|
test("deleteNodeById on no-match returns deleted===0 and leaves content", () => {
|
|
const input = doc(para("p-1", textNode("one")));
|
|
const { doc: out, deleted } = deleteNodeById(input, "missing");
|
|
assert.equal(deleted, 0);
|
|
assert.equal(out.content.length, 1);
|
|
});
|
|
|
|
test("deleteNodeById removes a node nested inside a callout", () => {
|
|
const input = doc(
|
|
callout("c-1", para("inner", textNode("x")), para("keep", textNode("y"))),
|
|
);
|
|
const { doc: out, deleted } = deleteNodeById(input, "inner");
|
|
assert.equal(deleted, 1);
|
|
assert.deepEqual(
|
|
out.content[0].content.map((n) => n.attrs.id),
|
|
["keep"],
|
|
);
|
|
});
|
|
|
|
test("deleteNodeById removes EVERY node sharing the id", () => {
|
|
const input = doc(
|
|
para("dup", textNode("a")),
|
|
para("keep", textNode("b")),
|
|
para("dup", textNode("c")),
|
|
);
|
|
const { doc: out, deleted } = deleteNodeById(input, "dup");
|
|
assert.equal(deleted, 2);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["keep"],
|
|
);
|
|
});
|
|
|
|
test("deleteNodeById does NOT mutate input (deep-equal snapshot)", () => {
|
|
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
const snap = snapshot(input);
|
|
const { doc: out } = deleteNodeById(input, "p-2");
|
|
assert.deepEqual(input, snap);
|
|
assert.notEqual(out, input);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// insertNodeRelative
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("insertNodeRelative before by anchorNodeId", () => {
|
|
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorNodeId: "p-2",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative after by anchorNodeId", () => {
|
|
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorNodeId: "p-1",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative before/after by anchorNodeId reaches a nested sibling", () => {
|
|
const input = doc(
|
|
callout("c-1", para("a", textNode("a")), para("b", textNode("b"))),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorNodeId: "a",
|
|
});
|
|
assert.equal(inserted, true);
|
|
// Inserted as a sibling inside the callout's content array.
|
|
assert.deepEqual(
|
|
out.content[0].content.map((n) => n.attrs.id),
|
|
["a", "new", "b"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative before by anchorText (top-level)", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "beta",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative after by anchorText (top-level)", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorText: "alpha",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative anchorText scans TOP-LEVEL blocks via recursive plain text", () => {
|
|
// anchorText matches the FIRST top-level block whose (recursive) blockPlainText
|
|
// includes the string. "deeptext" lives nested in a top-level callout, so the
|
|
// callout itself is the matched top-level block and the node lands as its
|
|
// sibling at the top level (not inside the callout).
|
|
const input = doc(
|
|
callout("c-1", para("inner", textNode("deeptext"))),
|
|
para("p-2", textNode("tail")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorText: "deeptext",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["c-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative anchorText does NOT match text only present below top level when no top-level block contains it", () => {
|
|
// The only block whose plain text includes "lonely" is a paragraph nested two
|
|
// levels deep, but the top-level scan still sees it through the callout's
|
|
// recursive plain text. To prove the scan is TOP-LEVEL (parent-array) only,
|
|
// assert the insertion happens at the top level beside the callout, never
|
|
// inside it.
|
|
const input = doc(callout("c-1", para("inner", textNode("lonely word"))));
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "lonely",
|
|
});
|
|
assert.equal(inserted, true);
|
|
// Inserted at the top level (siblings of the callout), not into the callout.
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["new", "c-1"],
|
|
);
|
|
// The callout's own children are untouched.
|
|
assert.deepEqual(
|
|
out.content[1].content.map((n) => n.attrs.id),
|
|
["inner"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative append pushes the node at the end of top-level content", () => {
|
|
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "append",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "p-2", "new"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative inserted===false when anchorNodeId missing", () => {
|
|
const input = doc(para("p-1", textNode("one")));
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorNodeId: "nope",
|
|
});
|
|
assert.equal(inserted, false);
|
|
assert.deepEqual(out, input);
|
|
});
|
|
|
|
test("insertNodeRelative inserted===false when anchorText missing", () => {
|
|
const input = doc(para("p-1", textNode("one")));
|
|
const node = para("new", textNode("NEW"));
|
|
const { inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "nomatch",
|
|
});
|
|
assert.equal(inserted, false);
|
|
});
|
|
|
|
test("insertNodeRelative does NOT mutate input (deep-equal snapshot)", () => {
|
|
const input = doc(para("p-1", textNode("one")), para("p-2", textNode("two")));
|
|
const snap = snapshot(input);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorNodeId: "p-1",
|
|
});
|
|
assert.deepEqual(input, snap);
|
|
assert.notEqual(out, input);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// anchorText markdown-normalization fallback (locating only)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("insertNodeRelative before by markdown-wrapped anchorText matches the plain block", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
// "**beta**" has no verbatim match; the stripped "beta" matches "p-2".
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "**beta**",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative after by markdown-wrapped anchorText matches the plain block", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorText: "**alpha**",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative anchorText with markdown AND a trailing emoji matches the plain block", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "**beta** ✨",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative exact anchorText still wins (no normalization)", () => {
|
|
// A block literally contains "a*b"; the exact anchor must match it directly.
|
|
const input = doc(
|
|
para("p-1", textNode("a*b")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { doc: out, inserted } = insertNodeRelative(input, node, {
|
|
position: "after",
|
|
anchorText: "a*b",
|
|
});
|
|
assert.equal(inserted, true);
|
|
assert.deepEqual(
|
|
out.content.map((n) => n.attrs.id),
|
|
["p-1", "new", "p-2"],
|
|
);
|
|
});
|
|
|
|
test("insertNodeRelative truly-missing anchor still returns inserted:false", () => {
|
|
const input = doc(
|
|
para("p-1", textNode("alpha")),
|
|
para("p-2", textNode("beta")),
|
|
);
|
|
const node = para("new", textNode("NEW"));
|
|
const { inserted } = insertNodeRelative(input, node, {
|
|
position: "before",
|
|
anchorText: "**gamma**",
|
|
});
|
|
assert.equal(inserted, false);
|
|
});
|
|
|
|
// assertUnambiguousMatch (#159, #185 review pt 2): the patchNode/deleteNode
|
|
// guard. Docmost duplicates block ids on copy/paste, so a write by id that
|
|
// matches >1 node must be REFUSED (the caller already skipped the write for any
|
|
// count !== 1; this reports the error). The duplicate COUNT itself is covered by
|
|
// the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc).
|
|
test("assertUnambiguousMatch: count 0 throws 'no node found'", () => {
|
|
assert.throws(
|
|
() => assertUnambiguousMatch("patchNode", "replace", 0, "n1", "p1"),
|
|
/patchNode: no node with id "n1" found on page p1/,
|
|
);
|
|
});
|
|
|
|
test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => {
|
|
assert.throws(
|
|
() => assertUnambiguousMatch("patchNode", "replace", 2, "dup", "p1"),
|
|
/ambiguous.*Refusing to replace all of them; nothing was changed/,
|
|
);
|
|
assert.throws(
|
|
() => assertUnambiguousMatch("deleteNode", "delete", 3, "dup", "p1"),
|
|
/ambiguous.*Refusing to delete all of them; nothing was changed/,
|
|
);
|
|
});
|
|
|
|
test("assertUnambiguousMatch: exactly one match does NOT throw", () => {
|
|
assert.doesNotThrow(() =>
|
|
assertUnambiguousMatch("patchNode", "replace", 1, "n1", "p1"),
|
|
);
|
|
assert.doesNotThrow(() =>
|
|
assertUnambiguousMatch("deleteNode", "delete", 1, "n1", "p1"),
|
|
);
|
|
});
|