Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 4c1ee50dc9 test(#351): close the mark-attr coverage hole + reclassify table spans (review round 1)
F1 [WARNING] The 'no invisible coverage hole' guard enumerated only
schema.nodes, so MARK attributes silently escaped the value-fuzz completeness
check — link.internal/target/rel/class are never fuzzed and nothing flagged it,
and a new attributed mark would slip through. Added allSchemaMarkAttrKeys() plus a
MARK_ATTR_FUZZED / MARK_ATTR_ALLOWLIST registry and two tests: every schema mark
attr must be in exactly one set (a new one turns it red), and neither set may hold
a stale row.

F2 [WARNING] The ACCEPTED annotation misclassified table colspan/rowspan as
having 'no md representation'. They DO round-trip — a spanned cell makes the
converter emit the whole table as a raw <table> with colspan/rowspan, which the
tiptap parser reads back. They are frozen only because generating a
geometrically-valid spanned table is deferred PR-2 structural work (the flat
generator hardcodes span = 1), not a markdown limit. Reclassified them as
DEFERRED-BUG (distinct from ACCEPTED) so a maintainer does not read them as an
inherent limitation; colwidth / backgroundColor(Name) stay ACCEPTED (the
raw-<table> fallback drops them).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:40:13 +03:00
agent_coder bfcee6dddc test(prosemirror-markdown): generative round-trip testing — attribute level, flat docs (#351 PR 1)
Schema-derived, property-based (fast-check) round-trip tests over flat
single-node ProseMirror documents. One test PR — src/ is untouched; the two
real bugs found are pinned as loud it.fails counterexamples, not fixed here.

- attr-arbitraries.ts: per-attribute four-state arbitraries (absent/default/
  nonDefault/degenerate), attribute list sourced from schema.nodes[t].spec.attrs;
  a documented override table supplies legal domains for constrained attrs and
  distinguishes two frozen classes explicitly — ACCEPTED limitations (no md
  representation) vs PINNED bugs (representable but dropped, tracked as
  counterexamples).
- text-arbitraries.ts: hostile text corpus (ported from the existing property
  test's supported-space guarantees).
- node-generators.ts: flat single-node generators + a completeness contract —
  every one of the schema's 45 nodes / 12 marks is either generated or listed in
  KNOWN_UNCOVERED with a reason.
- flat-roundtrip.property.test.ts: P1 (semantic round-trip via
  docsCanonicallyEqual), P2 (second-pass byte fixpoint — anti GS-EDIT-REVERT),
  P3 (totality), generator validity via schema.check(), and an explicit
  attribute-value-coverage snapshot so the not-fuzzed set can never grow silently.
- counterexamples: column.width (% dropped on parseFloat -> P2 churn) and
  orderedList.start (non-1 start renders as '1.' -> P1 loss) pinned as it.fails.

SEED=20250705, NUM_RUNS=300 per property; ~17s, no OOM (union arbitraries).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:38:40 +03:00
12 changed files with 2042 additions and 829 deletions
@@ -539,115 +539,3 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
});
});
/**
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
* but two wirings actually CHANGED in the migration and are otherwise untested:
* - movePage now forwards the newly-added optional `position` field to the
* client (client.movePage(pageId, parentPageId, position));
* - the table trio unified its `tableRef` param to `table` and must forward it
* positionally. A field destructured under the wrong name would silently pass
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
*/
describe('AiChatToolsService #294 changed execute wirings', () => {
const calls: Record<string, unknown[][]> = {
movePage: [],
tableInsertRow: [],
tableDeleteRow: [],
tableUpdateCell: [],
};
const fakeClient: Partial<DocmostClientLike> = {
movePage: (...args: unknown[]) => {
calls.movePage.push(args);
return Promise.resolve({ success: true });
},
tableInsertRow: (...args: unknown[]) => {
calls.tableInsertRow.push(args);
return Promise.resolve({ ok: true });
},
tableDeleteRow: (...args: unknown[]) => {
calls.tableDeleteRow.push(args);
return Promise.resolve({ ok: true });
},
tableUpdateCell: (...args: unknown[]) => {
calls.tableUpdateCell.push(args);
return Promise.resolve({ ok: true });
},
};
const tokenServiceStub = {
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
};
let service: AiChatToolsService;
beforeEach(() => {
for (const k of Object.keys(calls)) calls[k].length = 0;
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
mockLoaded(function () {
return fakeClient as DocmostClientLike;
} as unknown as loader.DocmostClientCtor),
);
service = new AiChatToolsService(
tokenServiceStub as never,
{} as never,
{} as never,
{} as never,
{} as never,
{
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
} as never,
);
});
afterEach(() => jest.restoreAllMocks());
const buildTools = () =>
service.forUser(
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
'session-1',
'ws-1',
'chat-1',
);
it('movePage forwards the optional position to the client', async () => {
const tools = await buildTools();
await tools.movePage.execute(
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
{} as never,
);
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
});
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
const tools = await buildTools();
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
expect(calls.movePage).toEqual([['p2', null, undefined]]);
});
it('tableInsertRow forwards the unified `table` param positionally', async () => {
const tools = await buildTools();
await tools.tableInsertRow.execute(
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
{} as never,
);
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
});
it('tableDeleteRow forwards `table` positionally', async () => {
const tools = await buildTools();
await tools.tableDeleteRow.execute(
{ pageId: 'p1', table: '#0', index: 1 } as never,
{} as never,
);
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
});
it('tableUpdateCell forwards `table` positionally', async () => {
const tools = await buildTools();
await tools.tableUpdateCell.execute(
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
{} as never,
);
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
});
});
@@ -316,27 +316,50 @@ export class AiChatToolsService {
execute: async () => resolveCurrentPageResult(openedPage),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's { title, markdown } projection.
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
getPage: tool({
description:
'Fetch a single page as Markdown by its page id. Returns the page ' +
'title and its Markdown content. Inline <span data-comment-id> tags ' +
'in the markdown are comment highlight anchors (also present for ' +
'RESOLVED threads) — treat them as markup, not page text.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id (or slugId) of the page.'),
}),
execute: async ({ pageId }) => {
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
const result = await client.getPage(pageId);
const data = (result?.data ?? {}) as {
title?: string;
content?: string;
};
return {
title: data.title ?? '',
markdown: typeof data.content === 'string' ? data.content : '',
};
},
}),
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
createPage: sharedTool(
sharedToolSpecs.createPage,
async ({ title, content, spaceId, parentPageId }) => {
createPage: tool({
description:
'Create a new page with a Markdown body in a space, optionally under ' +
'a parent page. Returns the new page id and title. Reversible: a page ' +
'can be moved to trash later.',
inputSchema: modelFriendlyInput({
title: z.string().describe('The title of the new page.'),
content: z
.string()
.describe('The page body as Markdown (may be empty).'),
spaceId: z
.string()
.describe('The id of the space to create the page in.'),
parentPageId: z
.string()
.optional()
.describe('Optional parent page id to nest the new page under.'),
}),
execute: async ({ title, content, spaceId, parentPageId }) => {
// createPage(title, content, spaceId, parentPageId?) ->
// { data: filterPage(page, markdown), success }.
const result = await client.createPage(
@@ -352,7 +375,7 @@ export class AiChatToolsService {
};
return { id: data.id ?? data.slugId, title: data.title ?? title };
},
),
}),
updatePageContent: tool({
description:
@@ -376,46 +399,115 @@ export class AiChatToolsService {
},
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
renamePage: sharedTool(
sharedToolSpecs.renamePage,
async ({ pageId, title }) => {
renamePage: tool({
description:
"Rename a page (change its title only; the body is untouched). " +
'Reversible: rename back at any time.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to rename.'),
title: z.string().describe('The new title.'),
}),
execute: async ({ pageId, title }) => {
// renamePage(pageId, title) -> { success, pageId, title }.
await client.renamePage(pageId, title);
return { pageId, title };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The shared schema adds the optional `position` field this layer lacked
// before; the execute now forwards it (the client already accepted it).
movePage: sharedTool(
sharedToolSpecs.movePage,
async ({ pageId, parentPageId, position }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null, position);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
// permanentlyDelete/forceDelete are never part of the input and can never
// be forwarded — the agent physically cannot permanently delete a page.
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// This layer keeps only its own execute-side guards (require a selection
// for a top-level comment; reject suggestedText on a reply / without a
// selection) — the schema+description are shared.
createComment: sharedTool(
sharedToolSpecs.createComment,
async ({
movePage: tool({
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move.'),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
'Target parent page id. Null/omitted moves the page to the ' +
'space root.',
),
}),
execute: async ({ pageId, parentPageId }) => {
// movePage(pageId, parentPageId, position?) -> raw move response.
await client.movePage(pageId, parentPageId ?? null);
return { pageId, parentPageId: parentPageId ?? null, moved: true };
},
}),
deletePage: tool({
description:
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
'page can be restored from trash). This NEVER permanently deletes.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to move to trash.'),
}),
// GUARDRAIL (§14 H4): the only field ever passed to the client is
// pageId. permanentlyDelete/forceDelete are not part of the schema and
// are never forwarded, so the agent physically cannot permanently
// delete a page through this tool.
execute: async ({ pageId }) => {
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
// which is the soft-delete (trash) path on the server.
await client.deletePage(pageId);
return { pageId, trashed: true };
},
}),
// INTENTIONAL per-transport divergence (not shared): the description is
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
// keeps its own wording. Kept per-layer.
createComment: tool({
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
'comment (one level only — the backend rejects replies to replies). ' +
'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' +
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().describe('The comment body as Markdown.'),
selection: z
.string()
.min(1)
.max(250)
.optional()
.describe(
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
'formatting boundaries). Required for a new top-level comment; ' +
'omit only when replying via parentCommentId.',
),
parentCommentId: z
.string()
.optional()
.describe(
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
'must be UNIQUE in the page — expand it with surrounding context ' +
'(still <=250 chars) if it occurs more than once, or the call is ' +
'refused.',
),
}),
execute: async ({
pageId,
content,
selection,
@@ -456,17 +548,26 @@ export class AiChatToolsService {
const data = (result?.data ?? {}) as { id?: string };
return { commentId: data.id, pageId };
},
),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
resolveComment: sharedTool(
sharedToolSpecs.resolveComment,
async ({ commentId, resolved }) => {
resolveComment: tool({
description:
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
'the resolved flag). Only top-level comments can be resolved.',
inputSchema: modelFriendlyInput({
commentId: z
.string()
.describe('The id of the top-level comment to resolve/reopen.'),
resolved: z
.boolean()
.describe('true to resolve the thread, false to reopen it.'),
}),
execute: async ({ commentId, resolved }) => {
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
await client.resolveComment(commentId, resolved);
return { commentId, resolved };
},
),
}),
// --- READ tools (added) ---
@@ -484,12 +585,33 @@ export class AiChatToolsService {
// hierarchy mode but is worded for the in-app agent; the standalone MCP
// `list_pages` carries its own wording. Kept per-layer so each side tunes
// its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listPages: sharedTool(
sharedToolSpecs.listPages,
async ({ spaceId, limit, tree }) =>
listPages: tool({
description:
'List the most recent pages, optionally scoped to a single space. ' +
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
inputSchema: modelFriendlyInput({
spaceId: z
.string()
.optional()
.describe('Optional space id to scope the listing to.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Maximum number of pages (1-100).'),
tree: z
.boolean()
.optional()
.describe(
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
),
}),
execute: async ({ spaceId, limit, tree }) =>
await client.listPages(spaceId, limit, tree),
),
}),
listSidebarPages: tool({
description:
@@ -534,34 +656,41 @@ export class AiChatToolsService {
}),
),
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
// while this key is `getTable` (verb-first), breaking the
// snake_case(inAppKey) convention the shared registry enforces. Its
// reference parameter is still named `table` (was `tableRef`) so it matches
// the migrated table row/cell tools below.
getTable: tool({
description:
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
'matrix so cells can be addressed for rich edits).',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
table: z
tableRef: z
.string()
.describe(
'"#<index>" from the page outline, or a block id of any node ' +
'inside the table.',
'"#<index>" from getOutline, or a block id of any node inside ' +
'the table.',
),
}),
execute: async ({ pageId, table }) =>
await client.getTable(pageId, table),
execute: async ({ pageId, tableRef }) =>
await client.getTable(pageId, tableRef),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
listComments: sharedTool(
sharedToolSpecs.listComments,
async ({ pageId, includeResolved }) =>
listComments: tool({
description:
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
'threads are returned; resolved threads (a resolved top-level ' +
'comment and all its replies) are hidden and their count reported ' +
'as `resolvedThreadsHidden` so you can re-query with ' +
'`includeResolved: true` to see everything. Returns ' +
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
includeResolved: z
.boolean()
.optional()
.describe('default only active threads; true — include resolved'),
}),
execute: async ({ pageId, includeResolved }) =>
await client.listComments(pageId, includeResolved),
),
}),
getComment: tool({
description: 'Fetch a single comment by id (content as Markdown).',
@@ -571,12 +700,26 @@ export class AiChatToolsService {
execute: async ({ commentId }) => await client.getComment(commentId),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
checkNewComments: sharedTool(
sharedToolSpecs.checkNewComments,
async ({ spaceId, since, parentPageId }) =>
checkNewComments: tool({
description:
'Find new comments across a space (optionally scoped to a subtree) ' +
'created after a given timestamp.',
inputSchema: modelFriendlyInput({
spaceId: z.string().describe('The id of the space to scan.'),
since: z
.string()
.describe('An ISO-8601 timestamp; only comments created after it.'),
parentPageId: z
.string()
.optional()
.describe(
'Optional page id to scope the scan to that page and its ' +
'descendants.',
),
}),
execute: async ({ spaceId, since, parentPageId }) =>
await client.checkNewComments(spaceId, since, parentPageId),
),
}),
listShares: sharedTool(
sharedToolSpecs.listShares,
@@ -606,14 +749,19 @@ export class AiChatToolsService {
await client.diffPageVersions(pageId, from, to),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
exportPageMarkdown: sharedTool(
sharedToolSpecs.exportPageMarkdown,
async ({ pageId }) => {
exportPageMarkdown: tool({
description:
'Export a page to a single self-contained Docmost-flavoured ' +
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
'with importPageMarkdown.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to export.'),
}),
execute: async ({ pageId }) => {
const markdown = await client.exportPageMarkdown(pageId);
return { markdown };
},
),
}),
// --- WRITE tools (added; reversible via page history/trash) ---
@@ -663,12 +811,28 @@ export class AiChatToolsService {
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The execute body keeps this layer's content normalization (parity with
// the standalone MCP server, index.ts update_page_json).
updatePageJson: sharedTool(
sharedToolSpecs.updatePageJson,
async ({ pageId, content, title }) => {
updatePageJson: tool({
description:
"Replace a page's body with a full ProseMirror document — a full " +
'overwrite — and/or update its title. Minimal example content: ' +
'{"type":"doc","content":[{"type":"paragraph","content":' +
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
'object or a JSON string (both accepted). Omit content for a ' +
'title-only update. Reversible: the previous version is kept in page ' +
'history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to update.'),
content: z
.any()
.optional()
.describe(
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
'object or JSON string); omit for a title-only update.',
),
title: z.string().optional().describe('Optional new title.'),
}),
execute: async ({ pageId, content, title }) => {
// Parity with the standalone MCP server (index.ts update_page_json):
// undefined/null pass through as undefined (title-only / no-op); any
// string is JSON.parsed (so an empty string "" throws, matching the
// MCP server); an object is passed through unchanged.
@@ -681,29 +845,66 @@ export class AiChatToolsService {
}
return await client.updatePageJson(pageId, doc, title);
},
),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool(
sharedToolSpecs.tableInsertRow,
async ({ pageId, table, cells, index }) =>
await client.tableInsertRow(pageId, table, cells, index),
),
// NOT in the shared registry: this layer names the table argument
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
// Sharing one buildShape would rename a model-facing parameter on one
// transport, so the table row/cell tools stay per-layer by design.
tableInsertRow: tool({
description:
'Insert a row of plain-text cells into a table. Reversible via ' +
'page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
cells: z.array(z.string()).describe('The cell texts for the row.'),
index: z
.number()
.int()
.optional()
.describe('0-based insert position (omit/out-of-range to append).'),
}),
execute: async ({ pageId, tableRef, cells, index }) =>
await client.tableInsertRow(pageId, tableRef, cells, index),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableDeleteRow: sharedTool(
sharedToolSpecs.tableDeleteRow,
async ({ pageId, table, index }) =>
await client.tableDeleteRow(pageId, table, index),
),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableDeleteRow: tool({
description:
'Delete a table row at a 0-based index. Reversible via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
index: z.number().int().describe('0-based row index to delete.'),
}),
execute: async ({ pageId, tableRef, index }) =>
await client.tableDeleteRow(pageId, tableRef, index),
}),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
tableUpdateCell: sharedTool(
sharedToolSpecs.tableUpdateCell,
async ({ pageId, table, row, col, text }) =>
await client.tableUpdateCell(pageId, table, row, col, text),
),
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
// divergence as tableInsertRow.
tableUpdateCell: tool({
description:
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
'Reversible via page history.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page.'),
tableRef: z
.string()
.describe('"#<index>" from getOutline, or a block id in the table.'),
row: z.number().int().describe('0-based row index.'),
col: z.number().int().describe('0-based column index.'),
text: z.string().describe('The new cell text.'),
}),
execute: async ({ pageId, tableRef, row, col, text }) =>
await client.tableUpdateCell(pageId, tableRef, row, col, text),
}),
copyPageContent: sharedTool(
sharedToolSpecs.copyPageContent,
@@ -717,14 +918,25 @@ export class AiChatToolsService {
await client.importPageMarkdown(pageId, markdown),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// Both layers already carried the security-confirmation framing, so there
// was no real divergence to preserve — only wording drift.
sharePage: sharedTool(
sharedToolSpecs.sharePage,
async ({ pageId, searchIndexing }) =>
// INTENTIONAL per-transport divergence (not shared): adds a security
// confirmation framing ("Only share when the user explicitly asked, since
// this exposes the page to anyone with the link") for the in-app agent; the
// standalone MCP `share_page` keeps the plain public-URL wording.
sharePage: tool({
description:
'Make a page PUBLICLY accessible and return its public URL. ' +
'Reversible via unsharePage. Only share when the user explicitly ' +
'asked, since this exposes the page to anyone with the link.',
inputSchema: modelFriendlyInput({
pageId: z.string().describe('The id of the page to share.'),
searchIndexing: z
.boolean()
.optional()
.describe('Allow public search engines to index it (default true).'),
}),
execute: async ({ pageId, searchIndexing }) =>
await client.sharePage(pageId, searchIndexing),
),
}),
unsharePage: sharedTool(
sharedToolSpecs.unsharePage,
@@ -100,26 +100,54 @@ export const INLINE_TOOL_TIERS: Record<
tier: 'core',
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
},
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
// (#294); they carry their own tier ('core') + catalogLine there.
// NOTE: createComment, listComments and resolveComment moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
// catalogLine there. getComment stays inline (MCP-only shape divergence is
// n/a — it simply has no shared spec).
getPage: {
tier: 'core',
catalogLine: 'getPage — fetch a page as Markdown by its id.',
},
listPages: {
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
},
listComments: {
tier: 'core',
catalogLine: 'listComments — list all comments on a page (including resolved).',
},
getComment: {
tier: 'core',
catalogLine: 'getComment — fetch a single comment by id.',
},
createComment: {
tier: 'core',
catalogLine:
'createComment — add an inline comment (optionally with a suggested edit).',
},
resolveComment: {
tier: 'core',
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
},
// --- deferred inline ---
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
// carry their own deferred tier + catalogLine there.
createPage: {
tier: 'deferred',
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
},
updatePageContent: {
tier: 'deferred',
catalogLine:
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
},
renamePage: {
tier: 'deferred',
catalogLine: "renamePage — change a page's title only (body untouched).",
},
movePage: {
tier: 'deferred',
catalogLine: 'movePage — move a page under a new parent or to the space root.',
},
deletePage: {
tier: 'deferred',
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
},
listSidebarPages: {
tier: 'deferred',
catalogLine:
@@ -129,21 +157,42 @@ export const INLINE_TOOL_TIERS: Record<
tier: 'deferred',
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
},
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
// snake_case(inAppKey) convention, so it has no shared spec).
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
// it carries its own deferred tier + catalogLine there.
checkNewComments: {
tier: 'deferred',
catalogLine:
'checkNewComments — find comments in a space created after a timestamp.',
},
getPageHistory: {
tier: 'deferred',
catalogLine:
'getPageHistory — fetch one page-history version with its ProseMirror content.',
},
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
// its own deferred tier + catalogLine there. transformPage stays inline (its
// schema deliberately diverges — it omits the deleteComments field the MCP
// docmost_transform exposes, a comment-deletion guardrail).
exportPageMarkdown: {
tier: 'deferred',
catalogLine:
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
},
updatePageJson: {
tier: 'deferred',
catalogLine:
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
},
tableInsertRow: {
tier: 'deferred',
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
},
tableDeleteRow: {
tier: 'deferred',
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
},
tableUpdateCell: {
tier: 'deferred',
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
},
sharePage: {
tier: 'deferred',
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
},
transformPage: {
tier: 'deferred',
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
+355 -83
View File
@@ -118,19 +118,56 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
// the in-app copy keeps the same tree option but is worded for the in-app agent.
// Kept per-layer so each side can tune its own guidance.
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
});
server.registerTool(
"list_pages",
{
description:
"List most recent pages in a space ordered by updatedAt (descending). " +
"Returns a bounded list (default 50, max 100) — use search for lookups " +
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
"space's full page hierarchy as a nested tree.",
inputSchema: {
spaceId: z.string().optional(),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Max pages to return (default 50, max 100)"),
tree: z
.boolean()
.optional()
.describe(
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
),
},
},
async ({ spaceId, limit, tree }) => {
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
return jsonContent(result);
},
);
// Tool: get_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
});
server.registerTool(
"get_page",
{
description:
"Get page details with content converted to Markdown. The conversion is " +
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
"lossless representation use get_page_json. Inline <span data-comment-id> " +
"tags in the markdown are comment highlight anchors (also present for " +
"RESOLVED threads) — treat them as markup, not page text.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
const page = await docmostClient.getPage(pageId);
return jsonContent(page);
},
);
// Tool: get_page_json
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
@@ -164,10 +201,6 @@ registerShared(
);
// Tool: table_get
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
// Renaming the public MCP tool would break external clients, so it stays inline.
server.registerTool(
"table_get",
{
@@ -190,10 +223,25 @@ server.registerTool(
);
// Tool: table_insert_row
// Schema + description now live in the shared registry (#294); the `table`
// parameter name is the canonical one (the in-app layer was unified to it).
registerShared(
SHARED_TOOL_SPECS.tableInsertRow,
// NOT in the shared registry: this transport names the table argument `table`,
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
// one buildShape would rename a public MCP parameter, so the table row/cell
// tools stay per-transport by design.
server.registerTool(
"table_insert_row",
{
description:
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
"a block id inside it. `cells` = text per column (padded to the table's " +
"column count; error if more cells than columns). `index` = 0-based " +
"insert position (0 inserts before the header); omit to append at the end.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
cells: z.array(z.string()),
index: z.number().int().optional(),
},
},
async ({ pageId, table, cells, index }) => {
const result = await docmostClient.tableInsertRow(
pageId,
@@ -206,9 +254,22 @@ registerShared(
);
// Tool: table_delete_row
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableDeleteRow,
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_delete_row",
{
description:
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
"a block id inside it). Refuses to delete the table's only row. An " +
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
"and the next row becomes the new header.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
index: z.number().int(),
},
},
async ({ pageId, table, index }) => {
const result = await docmostClient.tableDeleteRow(pageId, table, index);
return jsonContent(result);
@@ -216,9 +277,24 @@ registerShared(
);
// Tool: table_update_cell
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.tableUpdateCell,
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
// divergence as table_insert_row.
server.registerTool(
"table_update_cell",
{
description:
"Set the plain-text content of cell [row,col] (0-based) in a table " +
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
"content with a single text paragraph; for rich formatting use patch_node " +
"on the cell's paragraph id from table_get.",
inputSchema: {
pageId: z.string().min(1),
table: z.string().min(1),
row: z.number().int(),
col: z.number().int(),
text: z.string(),
},
},
async ({ pageId, table, row, col, text }) => {
const result = await docmostClient.tableUpdateCell(
pageId,
@@ -232,9 +308,22 @@ registerShared(
);
// Tool: create_page
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.createPage,
server.registerTool(
"create_page",
{
description:
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
"it under a parent; omit it to create at the space root.",
inputSchema: {
title: z.string().min(1).describe("Title of the page"),
content: z.string().min(1).describe("Markdown content"),
spaceId: z.string().min(1),
parentPageId: z
.string()
.optional()
.describe("Optional parent page ID to nest under"),
},
},
async ({ title, content, spaceId, parentPageId }) => {
const result = await docmostClient.createPage(
title,
@@ -247,11 +336,32 @@ registerShared(
);
// Tool: update_page_json
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's content normalization (parse a JSON-string content,
// pass undefined/null through for a title-only/no-op update).
registerShared(
SHARED_TOOL_SPECS.updatePageJson,
server.registerTool(
"update_page_json",
{
description:
"Replace a page's content with a raw ProseMirror JSON document " +
"(lossless write: preserves the block ids, callouts, tables and " +
"attributes you pass in). Typical flow: get_page_json -> modify the " +
"JSON -> update_page_json. Keep existing node ids intact so heading " +
"anchors and history stay stable. Minimal full-doc example: " +
'{"type":"doc","content":[{"type":"paragraph","content":' +
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
"the title (though prefer rename_page for a title-only change). " +
"Supplying neither content nor title is an error.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to update"),
content: z
.any()
.optional()
.describe(
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
"JSON string). Omit to rename only.",
),
title: z.string().optional().describe("Optional new title"),
},
},
async ({ pageId, content, title }) => {
// Only parse/validate the document when it was actually supplied; when it
// is omitted, pass it straight through so the client performs a title-only
@@ -269,11 +379,26 @@ registerShared(
);
// Tool: export_page_markdown
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
});
server.registerTool(
"export_page_markdown",
{
description:
"Export a page to a single self-contained, lossless Docmost-flavoured " +
"Markdown file (custom extensions): YAML-free meta header, body with " +
"inline comment anchors and diagrams, and a trailing comments-thread " +
"block. Designed for a download -> edit body -> import_page_markdown " +
"round-trip that preserves everything, including comment highlights. " +
"Comment THREADS are preserved in the file but are not re-pushed to the " +
"server on import.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
const md = await docmostClient.exportPageMarkdown(pageId);
return { content: [{ type: "text" as const, text: md }] };
},
);
// Tool: import_page_markdown
registerShared(
@@ -297,11 +422,22 @@ registerShared(
);
// Tool: rename_page
// Schema + description now live in the shared registry (#294).
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
});
server.registerTool(
"rename_page",
{
description:
"Rename a page (change its title only) without touching or resending " +
"its content.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to rename"),
title: z.string().min(1).describe("New title"),
},
},
async ({ pageId, title }) => {
const result = await docmostClient.renamePage(pageId, title);
return jsonContent(result);
},
);
// Tool: edit_page_text
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
@@ -380,10 +516,6 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
});
// Tool: insert_image
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
// exposes no image tools (insert/replace), so there is no second layer to unify
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
// catalog-partition test forbids a spec without a live in-app tool (#294).
server.registerTool(
"insert_image",
{
@@ -429,7 +561,6 @@ server.registerTool(
);
// Tool: replace_image
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
server.registerTool(
"replace_image",
{
@@ -472,10 +603,25 @@ server.registerTool(
);
// Tool: share_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default.
registerShared(
SHARED_TOOL_SPECS.sharePage,
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
// security-confirmation framing ("only share when the user explicitly asked,
// since this exposes the page to anyone with the link") tuned for the in-app
// agent; this transport keeps the plain public-URL wording.
server.registerTool(
"share_page",
{
description:
"Make a page publicly accessible (idempotent) and return its public " +
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
"page content to ANYONE with the URL — do it only when explicitly asked.",
inputSchema: {
pageId: z.string().min(1).describe("ID of the page to share"),
searchIndexing: z
.boolean()
.optional()
.describe("Allow search engines to index the page (default true)"),
},
},
async ({ pageId, searchIndexing }) => {
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
return jsonContent(result);
@@ -495,11 +641,29 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
});
// Tool: move_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
// its positive-confirmation check on the move response.
registerShared(
SHARED_TOOL_SPECS.movePage,
server.registerTool(
"move_page",
{
description:
"Move a page under a new parent (nesting) or to the space root.",
inputSchema: {
pageId: z.string().min(1),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
"Target parent page ID. Pass 'null' or empty string to move to root.",
),
position: z
.string()
.min(5)
.optional()
.describe(
"fractional-index position key; min 5 chars; omit to append at the end.",
),
},
},
async ({ pageId, parentPageId, position }) => {
const finalParentId =
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
@@ -534,22 +698,49 @@ registerShared(
);
// Tool: delete_page
// Schema + description now live in the shared registry (#294). The shared schema
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
});
server.registerTool(
"delete_page",
{
description:
"Delete a single page by ID. SOFT delete only: the page is moved to " +
"trash and can be restored; nothing is permanently deleted.",
inputSchema: {
pageId: z.string().min(1),
},
},
async ({ pageId }) => {
await docmostClient.deletePage(pageId);
return {
content: [
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
],
};
},
);
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
// Tool: list_comments
registerShared(
SHARED_TOOL_SPECS.listComments,
server.registerTool(
"list_comments",
{
description:
"List comments on a page in one call (pagination is handled " +
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
"threads (a resolved top-level comment and all its replies) are hidden " +
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
"with `includeResolved: true` to see everything. Returns " +
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
inputSchema: {
pageId: z.string().describe("ID of the page"),
includeResolved: z
.boolean()
.optional()
.describe(
"default only active threads; true — include resolved",
),
},
},
async ({ pageId, includeResolved }) => {
const comments = await docmostClient.listComments(pageId, includeResolved);
return jsonContent(comments);
@@ -557,11 +748,55 @@ registerShared(
);
// Tool: create_comment
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guards (require a selection for a top-level
// comment; reject suggestedText on a reply / without a selection).
registerShared(
SHARED_TOOL_SPECS.createComment,
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
// and "Reversible via the comment UI"); this transport keeps its own wording.
server.registerTool(
"create_comment",
{
description:
"Create a new comment on a page. The comment is ALWAYS inline and is " +
"anchored to (highlights) its `selection` text — there are no page-level " +
"comments. Content is provided as Markdown and automatically converted. " +
"A top-level comment REQUIRES an exact `selection`; if the selection " +
"cannot be found in the page the call fails (no orphan comment is left). " +
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
"selection. You may also attach a `suggestedText` proposing a replacement " +
"for the `selection`; a human applies (or rejects) it from the UI. When " +
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
"page — expand it with surrounding context if it is ambiguous.",
inputSchema: {
pageId: z.string().describe("ID of the page to comment on"),
content: z.string().min(1).describe("Comment content in Markdown format"),
selection: z
.string()
.min(1)
// Enforce the documented 250-char cap to match the description above.
.max(250)
.optional()
.describe(
"EXACT contiguous text from a single paragraph/block to anchor the " +
"comment on (<=250 chars). Required for a top-level comment; omit " +
"only when replying via parentCommentId.",
),
parentCommentId: z
.string()
.optional()
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
"applied by a human via the UI (never auto-applied). REQUIRES a " +
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
"be UNIQUE in the page — expand it with surrounding context (still " +
"<=250 chars) if it occurs more than once, or the call is refused.",
),
},
},
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
if (!parentCommentId && (!selection || !selection.trim())) {
throw new Error(
@@ -637,9 +872,28 @@ server.registerTool(
);
// Tool: resolve_comment
// Schema + description now live in the shared registry (#294).
registerShared(
SHARED_TOOL_SPECS.resolveComment,
server.registerTool(
"resolve_comment",
{
description:
"Resolve (close) or reopen a comment thread. Only top-level comments can " +
"be resolved — the server rejects resolving a reply. Reversible: pass " +
"resolved=false to reopen. Resolving keeps the thread and its replies " +
"(unlike delete_comment, which permanently removes them).",
inputSchema: {
commentId: z
.string()
.min(1)
.describe("ID of the top-level comment thread to resolve or reopen"),
resolved: z
.boolean()
.optional()
.default(true)
.describe(
"true (default) marks the thread resolved/closed; false reopens it",
),
},
},
async ({ commentId, resolved }) => {
const result = await docmostClient.resolveComment(commentId, resolved);
return jsonContent(result);
@@ -647,10 +901,30 @@ registerShared(
);
// Tool: check_new_comments
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
registerShared(
SHARED_TOOL_SPECS.checkNewComments,
server.registerTool(
"check_new_comments",
{
description:
"Check for new comments across pages in a space since a given timestamp. " +
"Optionally scope to a page subtree (folder). Returns only comments " +
"created after the specified time.",
inputSchema: {
spaceId: z.string().describe("Space ID to check for new comments"),
since: z
.string()
.min(1)
.describe(
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
),
parentPageId: z
.string()
.optional()
.describe(
"Optional root page ID to scope the check to a subtree (folder). " +
"Only pages under this parent will be checked.",
),
},
},
async ({ spaceId, since, parentPageId }) => {
// Reject an unparseable timestamp up front: otherwise the comparison
// against NaN silently treats every comment as "not new" and the tool
@@ -779,8 +1053,6 @@ server.registerTool(
);
// Tool: insert_footnote
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
// footnote tool, so there is no second layer to unify — stays inline (#294).
server.registerTool(
"insert_footnote",
{
-494
View File
@@ -316,34 +316,6 @@ export const SHARED_TOOL_SPECS = {
// --- share management ---
// Unified from the per-layer inline definitions (#294). Both layers already
// carried the "only share when explicitly asked" security framing (the
// "per-transport divergence" note on the old inline copies was stale), so
// there was no real behavioral divergence to preserve — only wording drift.
sharePage: {
mcpName: 'share_page',
inAppKey: 'sharePage',
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
// in-app copy's reversibility note; keeps the security framing both had.
description:
'Make a page PUBLICLY accessible (idempotent) and return its public URL ' +
'(format: <app>/share/<key>/p/<slugId>). This exposes the page content ' +
'to ANYONE with the URL — only share when the user explicitly asked. ' +
'Reversible: unshare it later to revoke the public URL.',
tier: 'deferred',
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
// Reconciled: MCP's stricter .min(1) on pageId kept; field descriptions from
// the in-app copy. The MCP execute keeps its own `searchIndexing ?? true`
// default (a per-layer concern, not part of the shared schema).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to share.'),
searchIndexing: z
.boolean()
.optional()
.describe('Allow public search engines to index it (default true).'),
}),
},
unsharePage: {
mcpName: 'unshare_page',
inAppKey: 'unsharePage',
@@ -537,470 +509,4 @@ export const SHARED_TOOL_SPECS = {
pageId: z.string().min(1),
}),
},
// --- page tools (unified from the per-layer inline definitions, #294) ---
//
// Descriptions merge both layers (the MCP copy's richer structural notes + the
// in-app copy's "Reversible via history/trash" framing where it added one).
// Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the
// in-app layer deliberately allowed a looser value (documented per field).
getPage: {
mcpName: 'get_page',
inAppKey: 'getPage',
description:
'Fetch a single page as Markdown by its id. Returns the page title and ' +
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
'table/callout structure are approximated); for a lossless representation ' +
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
'are comment highlight anchors (also present for RESOLVED threads) — ' +
'treat them as markup, not page text.',
tier: 'core',
catalogLine: 'getPage — fetch a page as Markdown by its id.',
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
// "(or slugId)" describe kept.
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
}),
},
listPages: {
mcpName: 'list_pages',
inAppKey: 'listPages',
description:
'List the most recent pages (ordered by updatedAt, descending), ' +
'optionally scoped to a single space. Returns a bounded list (default ' +
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
"(with spaceId) to instead get the space's full page hierarchy as a " +
'nested tree.',
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
buildShape: (z) => ({
spaceId: z
.string()
.optional()
.describe('Optional space id to scope the listing to.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.optional()
.describe('Maximum number of pages (default 50, max 100).'),
tree: z
.boolean()
.optional()
.describe(
"When true, return the space's full page hierarchy as a nested tree " +
'(children arrays) instead of the recent-by-updatedAt flat list. ' +
'Requires spaceId; ignores limit.',
),
}),
},
createPage: {
mcpName: 'create_page',
inAppKey: 'createPage',
description:
'Create a new page with a Markdown body in a space, optionally under a ' +
'parent page (omit parentPageId to create at the space root). Returns ' +
'the new page id and title. Reversible: a page can be moved to trash ' +
'later.',
tier: 'deferred',
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
// ("may be empty") — creating an empty page to fill in later is a real use
// case. The looser (no-min) form is kept, so create_page now also accepts an
// empty body (harmless — it creates an empty page) and no previously-valid
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
// (an empty title or space is never valid).
buildShape: (z) => ({
title: z.string().min(1).describe('The title of the new page.'),
content: z.string().describe('The page body as Markdown (may be empty).'),
spaceId: z.string().min(1).describe('The id of the space to create the page in.'),
parentPageId: z
.string()
.optional()
.describe('Optional parent page id to nest the new page under.'),
}),
},
movePage: {
mcpName: 'move_page',
inAppKey: 'movePage',
description:
'Move a page under a new parent page, or to the space root when no ' +
'parent is given. Reversible: move it back at any time.',
tier: 'deferred',
catalogLine: 'movePage — move a page under a new parent or to the space root.',
// Reconciled schema DRIFT: the MCP copy exposed a `position` field
// (fractional-index ordering) that the in-app copy lacked. Unified by
// KEEPING position (the in-app client already accepts an optional position
// arg, so the in-app execute now forwards it) — it is optional, so no
// previously-valid in-app call is rejected. `parentPageId` is `.nullable()`
// on both, so a real JSON null moves to root on either transport; the MCP
// execute additionally coerces the strings 'null'/'' to null as a robustness
// fallback (kept in its execute body, not in the shared schema).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to move.'),
parentPageId: z
.string()
.nullable()
.optional()
.describe(
'Target parent page id. Null or omitted moves the page to the space ' +
'root.',
),
position: z
.string()
.min(5)
.optional()
.describe(
'Optional fractional-index position key (min 5 chars); omit to ' +
'append at the end.',
),
}),
},
renamePage: {
mcpName: 'rename_page',
inAppKey: 'renamePage',
description:
'Rename a page (change its title only; the body is untouched, never ' +
'resent). Reversible: rename back at any time.',
tier: 'deferred',
catalogLine: "renamePage — change a page's title only (body untouched).",
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to rename.'),
title: z.string().min(1).describe('The new title.'),
}),
},
deletePage: {
mcpName: 'delete_page',
inAppKey: 'deletePage',
description:
'Move a page to the trash — SOFT delete only: the page can be restored ' +
'from trash and nothing is ever permanently deleted.',
tier: 'deferred',
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
// GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a
// permanentlyDelete/forceDelete flag can never reach the client through this
// tool (asserted by ai-chat-tools.service.spec.ts).
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
}),
},
updatePageJson: {
mcpName: 'update_page_json',
inAppKey: 'updatePageJson',
description:
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
'write: preserves the block ids, callouts, tables and attributes you pass ' +
'in). Typical flow: read the page-JSON view -> modify the JSON -> write it back. ' +
'Keep existing node ids intact so heading anchors and history stay ' +
'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' +
'"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' +
'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' +
'to update only the title (though prefer the rename-page tool for a title-only ' +
'change). Supplying neither content nor title is an error. Reversible: ' +
'the previous version is kept in page history.',
tier: 'deferred',
catalogLine:
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
buildShape: (z) => ({
pageId: z.string().min(1).describe('ID of the page to update'),
content: z
.any()
.optional()
.describe(
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
'JSON string). Omit to update only the title.',
),
title: z.string().optional().describe('Optional new title'),
}),
},
exportPageMarkdown: {
mcpName: 'export_page_markdown',
inAppKey: 'exportPageMarkdown',
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
description:
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
'Markdown file (custom extensions): YAML-free meta header, body with ' +
'inline comment anchors and diagrams, and a trailing comments-thread ' +
'block. Designed for a download -> edit body -> page-Markdown import ' +
'round-trip that preserves everything, including comment highlights. ' +
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
'server on import.',
tier: 'deferred',
catalogLine:
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page to export.'),
}),
},
// --- comment tools (unified from the per-layer inline definitions, #294) ---
//
// create_comment and resolve_comment previously carried a "per-transport
// divergence" note in BOTH layers; #294 unifies their schema + description
// here. Only the four tools that genuinely exist in BOTH layers live in the
// registry: create/list/resolve comment and check_new_comments.
//
// update_comment and delete_comment are intentionally NOT here: they exist
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
// hard comment edit/delete tool (comment edits are irreversible / not
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
// so there is nothing to unify — they stay inline in index.ts.
createComment: {
mcpName: 'create_comment',
inAppKey: 'createComment',
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
// page-level comments, replies inherit the anchor, suggestedText must be
// unique — and adds the "retry with a corrected EXACT selection" and reply-
// to-reply-rejected guidance the MCP copy lacked. Execute-side validation
// (reject suggestedText on a reply, require a selection) stays per-layer.
description:
'Add an INLINE comment to a page, or reply to an existing top-level ' +
'comment (one level only — the backend rejects replies to replies). ' +
'The comment is anchored inline to the given exact `selection` text ' +
'(which gets highlighted); page-level comments are NOT supported. A ' +
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
"parent's anchor and take no selection. If the call fails with a " +
'"selection not found" error, retry with a corrected EXACT selection ' +
'copied verbatim from a single paragraph/block. You may also attach a ' +
'`suggestedText` proposing a replacement for the `selection` (a human ' +
'applies it from the UI); when set, the `selection` must occur exactly ' +
'once in the page. Reversible via the comment UI.',
tier: 'core',
catalogLine:
'createComment — add an inline comment (optionally with a suggested edit).',
// Reconciled schema: the field set is identical across both layers; the
// only constraint drift is `content`, which the MCP copy pinned to
// .min(1) while the in-app copy left unbounded — the stricter MCP form is
// kept (an empty comment body is never valid).
buildShape: (z) => ({
pageId: z.string().describe('The id of the page to comment on.'),
content: z.string().min(1).describe('The comment body as Markdown.'),
selection: z
.string()
.min(1)
.max(250)
.optional()
.describe(
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
'formatting boundaries). Required for a new top-level comment; ' +
'omit only when replying via parentCommentId.',
),
parentCommentId: z
.string()
.optional()
.describe(
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
'of replies only).',
),
suggestedText: z
.string()
.min(1)
.max(2000)
.optional()
.describe(
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
'must be UNIQUE in the page — expand it with surrounding context ' +
'(still <=250 chars) if it occurs more than once, or the call is ' +
'refused.',
),
}),
},
listComments: {
mcpName: 'list_comments',
inAppKey: 'listComments',
// CANONICAL: the two copies are near-identical; the MCP copy is the
// superset (it keeps the "(pagination is handled internally)" note the
// in-app copy dropped), so it is used verbatim.
description:
'List comments on a page in one call (pagination is handled ' +
'internally). By DEFAULT only ACTIVE threads are returned; resolved ' +
'threads (a resolved top-level comment and all its replies) are hidden ' +
'and their count reported as `resolvedThreadsHidden` so you can re-query ' +
'with `includeResolved: true` to see everything. Returns ' +
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
tier: 'core',
catalogLine:
'listComments — list all comments on a page (including resolved).',
buildShape: (z) => ({
pageId: z.string().describe('ID of the page'),
includeResolved: z
.boolean()
.optional()
.describe('default only active threads; true — include resolved'),
}),
},
resolveComment: {
mcpName: 'resolve_comment',
inAppKey: 'resolveComment',
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
// layer) — rephrased transport-neutrally per the registry convention.
description:
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
'pass resolved=false to reopen). Only top-level comments can be ' +
'resolved; the server rejects resolving a reply. Resolving keeps the ' +
'thread and its replies intact (it is not a deletion).',
tier: 'core',
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
// Reconciled schema: `resolved` drifted — the MCP copy made it optional
// with .default(true) (resolve is the common case, documented), the in-app
// copy made it required. The MCP form is kept (a strict superset: it never
// rejects a previously-valid input and adds a sensible default), and
// commentId keeps the MCP copy's stricter .min(1).
buildShape: (z) => ({
commentId: z
.string()
.min(1)
.describe('ID of the top-level comment thread to resolve or reopen'),
resolved: z
.boolean()
.optional()
.default(true)
.describe(
'true (default) marks the thread resolved/closed; false reopens it',
),
}),
},
checkNewComments: {
mcpName: 'check_new_comments',
inAppKey: 'checkNewComments',
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
// execute-side guard that rejects an unparseable `since` timestamp stays in
// its execute body (per-layer logic), not in the shared schema.
description:
'Check for new comments across pages in a space since a given ' +
'timestamp. Optionally scope to a page subtree (folder). Returns only ' +
'comments created after the specified time.',
tier: 'deferred',
catalogLine:
'checkNewComments — find comments in a space created after a timestamp.',
// Reconciled schema: `since` keeps the MCP copy's stricter .min(1) (the
// in-app copy left it unbounded); field descriptions use the MCP copy's
// more detailed wording (it carries an example timestamp).
buildShape: (z) => ({
spaceId: z.string().describe('Space ID to check for new comments'),
since: z
.string()
.min(1)
.describe(
"ISO 8601 timestamp — only return comments created after this time " +
"(e.g. '2026-03-10T00:00:00Z')",
),
parentPageId: z
.string()
.optional()
.describe(
'Optional root page ID to scope the check to a subtree (folder). ' +
'Only pages under this parent will be checked.',
),
}),
},
// --- table tools (unified from the per-layer inline definitions, #294) ---
//
// These tools carried a "NOT shared" note in BOTH layers because of a single
// parameter-NAME drift: the MCP layer named the table reference `table` while
// the in-app layer named it `tableRef`. #294 reconciles that drift by unifying
// on the MCP name `table` — renaming the MCP public parameter would break
// external MCP clients, whereas the in-app parameter is model-facing
// (prompt-only) and safe to rename. The in-app execute bodies now destructure
// `table` instead of `tableRef` (nothing else changes). Descriptions take the
// MCP copy's richer wording (it documented `#<index>`, padding, header-row
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
// tool references are phrased transport-neutrally.
//
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
// break external clients, so it stays per-transport (its in-app param was still
// aligned to `table` for consistency with the migrated trio below).
tableInsertRow: {
mcpName: 'table_insert_row',
inAppKey: 'tableInsertRow',
description:
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
'from the page outline, or a block id inside it. `cells` is the text per ' +
"column (padded to the table's column count; an error if more cells than " +
'columns). `index` is the 0-based insert position (0 inserts before the ' +
'header); omit to append at the end. Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
cells: z.array(z.string()).describe('The cell texts for the row (one per column).'),
index: z
.number()
.int()
.optional()
.describe('0-based insert position (0 inserts before the header); omit to append.'),
}),
},
tableDeleteRow: {
mcpName: 'table_delete_row',
inAppKey: 'tableDeleteRow',
description:
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
'from the page outline, or a block id inside it). Refuses to delete the ' +
"table's only row; an out-of-range `index` throws. Deleting `index` 0 " +
'removes the header row, and the next row becomes the new header. ' +
'Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
index: z.number().int().describe('0-based row index to delete.'),
}),
},
tableUpdateCell: {
mcpName: 'table_update_cell',
inAppKey: 'tableUpdateCell',
description:
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
'(`table` is `#<index>` from the page outline, or a block id inside it). ' +
"Replaces the cell's content with a single text paragraph; for rich " +
"formatting, patch the cell's paragraph id (obtained from reading the " +
'table) instead. Reversible via page history.',
tier: 'deferred',
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
buildShape: (z) => ({
pageId: z.string().min(1).describe('The id of the page.'),
table: z
.string()
.min(1)
.describe('"#<index>" from the page outline, or a block id in the table.'),
row: z.number().int().describe('0-based row index.'),
col: z.number().int().describe('0-based column index.'),
text: z.string().describe('The new cell text.'),
}),
},
} satisfies Record<string, SharedToolSpec>;
@@ -0,0 +1,16 @@
{
"_bug": "BUG #351: a `column` whose `width` is a percentage string (e.g. \"50%\") is NOT byte-stable across export->import->export (violates P2). The `column` schema's parseHTML does `parseFloat(getAttribute('data-width'))`, which silently drops the '%' unit and returns the NUMBER 50. So the first export emits data-width=\"50%\" but the re-import stores width=50, and the second export emits data-width=\"50\": md2 !== md1, a permanent GS-EDIT-REVERT churn (every git-sync pull rewrites the column width). The editor authors column widths as percentages, so this is a real data/round-trip defect. Fix belongs in src/lib/docmost-schema.ts column.width parseHTML (preserve the unit / keep the string), which is OUT OF SCOPE for this test-only PR and must be a separate, maintainer-approved change. This flat generator therefore keeps `column.width` frozen (never generates a non-default width).",
"doc": {
"type": "doc",
"content": [
{
"type": "columns",
"attrs": { "layout": "two_equal", "widthMode": "normal" },
"content": [
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "L" }] }] },
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "R" }] }] }
]
}
]
}
}
@@ -0,0 +1,19 @@
{
"doc": {
"type": "doc",
"content": [
{
"type": "orderedList",
"attrs": { "type": null, "start": 5 },
"content": [
{
"type": "listItem",
"content": [
{ "type": "paragraph", "content": [{ "type": "text", "text": "alpha" }] }
]
}
]
}
]
}
}
@@ -0,0 +1,325 @@
/**
* Schema-DERIVED attribute-state fast-check arbitraries (#351, PR 1).
*
* This GENERALIZES the #350 stability-matrix helper (roundtrip-stability.helper.ts)
* to fast-check. Where that helper sweeps a HAND-WRITTEN 2-state matrix for one
* node spec, this module reads the attribute list straight from
* `schema.nodes[type].spec.attrs` (never a hand list) and, per attribute,
* generates over the FOUR states the issue calls for:
*
* - `absent` : the attribute is OMITTED entirely (the empty-string-vs-
* absent churn class the #350 fix targets).
* - `default` : the schema default value, authored explicitly.
* - `nonDefault` : a representative legal non-default value.
* - `degenerate` : `""` for strings, `0`/negative for numbers, the flipped
* value for booleans.
*
* ── Why a per-attribute override table ──────────────────────────────────────
* Everything that CAN be derived generically from the default's runtime type is
* (booleans flip; the degenerate value follows the runtime type). But two facts
* force a small, DOCUMENTED override table:
*
* 1. CONSTRAINED domains the schema does not encode. `image.align ∈
* {left,center,right}`, `heading.level ∈ 1..6`, `callout.type ∈
* {info,success,warning,danger}`, `columns.layout`, table-cell `align`,
* `status.color`, `orderedList.start ≥ 1`, etc. A generic "default + 1"
* would emit an ILLEGAL value, so these get an explicit legal domain.
* 2. ROUND-TRIP-safety, established EMPIRICALLY by probing the live converter
* (the classification captured in flat-roundtrip.property.test.ts). A frozen
* attribute falls into ONE of TWO explicitly-distinguished classes — never a
* silent "it just doesn't round-trip":
*
* (a) ACCEPTED LIMITATION — the attribute has NO markdown representation,
* so the loss is inherent to targeting markdown, not a converter
* defect. These: `paragraph`/`heading` `indent`, `callout.icon`,
* `orderedList.type` (a/A/i markers), table `colwidth` /
* `backgroundColor(Name)` (dropped by the raw-<table> fallback). Each is
* tagged `// ACCEPTED:` inline. Freezing them is correct — there is
* nothing to preserve in the target format.
*
* (b) PINNED BUG — the attribute IS representable in markdown but the
* converter drops it anyway (a real defect). These are NOT silently
* frozen: each is captured as a LOUD `it.fails` counterexample in
* test/fixtures/counterexamples/ + counterexamples.test.ts, and the
* freeze here only keeps the P1/P2 union green until a MAINTAINER rules
* on accept-vs-fix (the epic guardrail reserves that call). These:
* `column.width` (parseFloat drops `%`), `orderedList.start` (non-1
* start renders as `1.`). Tagged `// PINNED-BUG:` inline.
*
* (c) DEFERRED-BUG — representable AND round-trips, frozen only because the
* flat generator can't yet build a valid instance. Table
* `colspan`/`rowspan` round-trip via the raw-<table> fallback, but a
* geometrically-valid spanned table is PR-2 structural work; the flat
* generator hardcodes span = 1. Tagged `// DEFERRED-BUG:` inline so a
* maintainer does not read them as an inherent limitation.
* - Several non-null-default attrs are MATERIALIZED on import but are not
* in canonicalize's KNOWN_DEFAULTS (`callout.type`, `status.color`,
* table `colspan`/`rowspan`, `columns.layout`/`widthMode`,
* `embed.width`/`height`, `heading.level`, `taskItem.checked`,
* `details.open`, `subpages.recursive`, `orderedList.start`). If left
* `absent` they re-materialize as a non-canonical default and diverge
* under P1. We mark them `always` so they are authored explicitly.
* - The documented numeric→string coercion set (`width height size
* aspectRatio`) is generated as STRINGS for the media family (a stored
* number re-parses as a string), EXCEPT `embed.width/height` which the
* embed schema keeps numeric — handled per-attr.
*
* Both PINNED-BUG attrs (`column.width` P2 churn, `orderedList.start` P1 loss)
* are captured as committed `it.fails` counterexamples — NOT hidden here.
*/
import fc from 'fast-check';
import { getSchema } from '@tiptap/core';
import { docmostExtensions } from '../../src/lib/index.js';
import { phraseArb, letterPhraseArb, urlArb } from './text-arbitraries.js';
/** The exact ProseMirror schema the converter targets. */
export const schema = getSchema(docmostExtensions as any);
/** Sentinel: this attribute is OMITTED (the `absent` state). */
export const ABSENT = Symbol('ABSENT');
/** The documented numeric→string coercion set (issue + roundtrip-stability.helper). */
export const NUMERIC_STRING_ATTRS = ['width', 'height', 'size', 'aspectRatio'];
/** Read the schema default for every attribute of a node type. */
export function schemaAttrDefaults(type: string): Record<string, unknown> {
const specAttrs = (schema.nodes[type]?.spec?.attrs ?? {}) as Record<
string,
{ default: unknown }
>;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(specAttrs)) out[k] = v.default;
return out;
}
/** Attribute names for a node type, straight from the schema (never hand-listed). */
export function schemaAttrNames(type: string): string[] {
return Object.keys((schema.nodes[type]?.spec?.attrs ?? {}) as object);
}
/**
* Per-attribute policy. Everything unlisted falls back to a generic policy:
* - a BOOLEAN default is fuzzable (its non-default is the flipped value);
* - any other default is `frozen` (only `absent`/`default` are generated) so
* we never invent an unverified non-default that might not round-trip.
* Listed attrs override this with a legal `arb` domain and/or flags.
*/
interface AttrPolicy {
/** Arbitrary for the `nonDefault` state's value. */
arb?: fc.Arbitrary<unknown>;
/** Value for the `degenerate` state (fuzz mode only). Omit to skip degenerate. */
degen?: unknown;
/** Never emit `absent` — the attr must be authored (materialized default class). */
always?: boolean;
/** Never emit the schema default value (required-ish attrs like `src`). Implies always. */
noDefault?: boolean;
/** Never emit non-default/degenerate — attr has no md representation or churns. */
frozen?: boolean;
}
const num = (...xs: number[]) => fc.constantFrom(...xs);
const str = (...xs: string[]) => fc.constantFrom(...xs);
const widthStr = str('120', '320', '640');
// The documented override table, keyed `type.attr`. Every entry is grounded in
// the empirical converter probe (see flat-roundtrip.property.test.ts header).
const OVERRIDES: Record<string, AttrPolicy> = {
// ── block text containers ────────────────────────────────────────────────
// 'left' is the IMPLICIT default alignment: the converter drops it on export
// (empirically confirmed), so it never round-trips. Only center/right/justify
// carry through the `<!--attrs {textAlign}-->` comment.
'paragraph.textAlign': { arb: str('center', 'right', 'justify') },
'paragraph.indent': { frozen: true }, // ACCEPTED: no md representation
'heading.level': { always: true, arb: num(2, 3, 4, 5, 6) },
'heading.textAlign': { arb: str('center', 'right', 'justify') },
'heading.indent': { frozen: true }, // ACCEPTED: no md representation
// ── lists ────────────────────────────────────────────────────────────────
// PINNED-BUG: markdown CAN express a non-1 start ("5."), but the converter
// renders "1." and drops it -> P1 loss. See counterexamples.test.ts
// (ordered-list-start.json). Frozen only until the maintainer rules accept-vs-fix.
'orderedList.start': { always: true, frozen: true },
'orderedList.type': { frozen: true }, // ACCEPTED: a/A/i markers not expressible in GFM
'taskItem.checked': { always: true, arb: fc.constant(true) }, // boolean, default false
// ── codeBlock ────────────────────────────────────────────────────────────
'codeBlock.language': { arb: str('js', 'ts', 'python', 'go', 'rust', 'bash') },
// ── image / media (numeric→string width family) ──────────────────────────
'image.src': { noDefault: true, arb: urlArb, degen: '' },
'image.align': { arb: str('left', 'right') },
'image.alt': { arb: letterPhraseArb, degen: '' },
'image.title': { arb: letterPhraseArb },
'image.width': { arb: widthStr, degen: '' },
'image.height': { arb: widthStr, degen: '' },
'video.src': { noDefault: true, arb: urlArb, degen: '' },
'video.alt': { arb: letterPhraseArb },
'video.width': { arb: widthStr },
'video.height': { arb: widthStr },
'audio.src': { noDefault: true, arb: urlArb, degen: '' },
'youtube.src': { noDefault: true, arb: urlArb },
'pdf.src': { noDefault: true, arb: urlArb },
'pdf.name': { arb: phraseArb },
'drawio.src': { noDefault: true, arb: urlArb },
'excalidraw.src': { noDefault: true, arb: urlArb },
'attachment.url': { noDefault: true, arb: urlArb },
'attachment.name': { arb: phraseArb },
// ── callout / status ─────────────────────────────────────────────────────
'callout.type': { always: true, arb: str('success', 'warning', 'danger') },
'callout.icon': { frozen: true }, // ACCEPTED: no md representation (dropped on export)
'status.text': { noDefault: true, arb: phraseArb, degen: '' },
'status.color': { always: true, arb: str('green', 'orange', 'red', 'blue', 'yellow', 'purple') },
// ── table cells ────────────────────────────────────────────────────────────
// DEFERRED-BUG (not ACCEPTED): colspan/rowspan ARE representable and round-trip
// — a spanned cell makes the converter emit the whole table as a raw <table>
// with colspan/rowspan attrs (markdown-converter.ts tableToHtml), which the
// tiptap parser reads back. They are frozen only because generating a
// geometrically-valid spanned table is deferred STRUCTURAL work (the flat
// generator hardcodes colspan/rowspan = 1), NOT a markdown limitation.
'tableCell.colspan': { always: true, frozen: true },
'tableCell.rowspan': { always: true, frozen: true },
// ACCEPTED: colwidth / backgroundColor(Name) have no representation — the
// raw-<table> fallback (tableToHtml) drops them, so there is nothing to preserve.
'tableCell.colwidth': { frozen: true },
'tableCell.backgroundColor': { frozen: true },
'tableCell.backgroundColorName': { frozen: true },
'tableCell.align': { arb: str('left', 'center', 'right') },
'tableHeader.colspan': { always: true, frozen: true }, // DEFERRED-BUG (see tableCell.colspan)
'tableHeader.rowspan': { always: true, frozen: true }, // DEFERRED-BUG (see tableCell.rowspan)
'tableHeader.colwidth': { frozen: true }, // ACCEPTED: no representation
'tableHeader.backgroundColor': { frozen: true }, // ACCEPTED: no representation
'tableHeader.backgroundColorName': { frozen: true }, // ACCEPTED: no representation
'tableHeader.align': { arb: str('left', 'center', 'right') },
// ── details ──────────────────────────────────────────────────────────────
'details.open': { always: true, arb: fc.constant(true) }, // boolean, default false
// ── columns ──────────────────────────────────────────────────────────────
'columns.layout': { always: true, arb: str('three_equal', 'left_sidebar', 'right_sidebar') },
// widthMode round-trips via the `data-width-mode` attribute (verified P1+P2),
// so it is fuzzed, not frozen.
'columns.widthMode': { always: true, arb: str('custom') },
// PINNED-BUG: parseFloat import drops the `%` unit -> P2 churn. See
// counterexamples.test.ts (columns-column-width-percent.json).
'column.width': { frozen: true },
// ── embed (schema keeps width/height NUMERIC, not string-coerced) ─────────
'embed.src': { noDefault: true, arb: urlArb, degen: '' },
'embed.provider': { noDefault: true, arb: str('iframe', 'youtube', 'vimeo') },
'embed.width': { always: true, frozen: true },
'embed.height': { always: true, frozen: true },
// ── subpages / math / htmlEmbed ──────────────────────────────────────────
'subpages.recursive': { always: true, arb: fc.constant(true) }, // boolean, default false
'mathBlock.text': { noDefault: true, arb: str('x^2', 'a < b', '\\frac{1}{2}'), degen: '' },
'mathInline.text': { noDefault: true, arb: str('x^2', 'a < b', '\\frac{1}{2}'), degen: '' },
'htmlEmbed.source': { noDefault: true, arb: str('<b>hi</b>', '<i>x</i>', '<span>y</span>'), degen: '' },
'htmlEmbed.height': { arb: num(200, 300, 400) },
// ── footnotes / transclusion / pageEmbed / mention ───────────────────────
'footnoteDefinition.id': { noDefault: true, arb: str('fn1', 'fn2', 'note') },
'footnoteReference.id': { noDefault: true, arb: str('fn1', 'fn2', 'note') },
'pageEmbed.sourcePageId': { noDefault: true, arb: fc.uuid() },
'transclusionSource.id': { noDefault: true, arb: str('src1', 'src2') },
'transclusionReference.sourcePageId': { noDefault: true, arb: fc.uuid() },
'transclusionReference.transclusionId': { noDefault: true, arb: str('tr1', 'tr2') },
'mention.id': { noDefault: true, arb: fc.uuid() },
'mention.label': { noDefault: true, arb: phraseArb },
'mention.entityType': { noDefault: true, arb: str('user') },
'mention.entityId': { noDefault: true, arb: fc.uuid() },
};
/** Resolve the effective policy for one attribute (override merged over generic). */
function policyFor(type: string, attr: string, def: unknown): AttrPolicy {
const override = OVERRIDES[`${type}.${attr}`];
if (override) return override;
// Generic: booleans are fuzzable via their flipped value; everything else is
// frozen (only absent/default) so no unverified non-default is invented.
if (typeof def === 'boolean') return { arb: fc.constant(!def) };
return { frozen: true };
}
/**
* Whether an attribute is actually exercised at a NON-DEFAULT value (i.e. its
* policy has an `arb`, which the generic fallback does not). Used by the
* attribute-coverage snapshot test to make the generic-frozen space VISIBLE: any
* string/number attr not in OVERRIDES is silently only tested at absent/default,
* so the snapshot pins exactly which attrs are NOT value-fuzzed and forces a
* reviewer to look when a new attr lands in that invisible bucket.
*/
export function attrIsValueFuzzed(type: string, attr: string): boolean {
const def = schemaAttrDefaults(type)[attr];
return !!policyFor(type, attr, def).arb;
}
/** Every node `type.attr` in the schema (excluding the auto `id`), sorted. */
export function allSchemaAttrKeys(): string[] {
const keys: string[] = [];
for (const type of Object.keys(schema.nodes)) {
for (const attr of schemaAttrNames(type)) {
if (attr === 'id') continue;
keys.push(`${type}.${attr}`);
}
}
return keys.sort();
}
/**
* Every MARK attribute in the schema, keyed `mark:<name>.<attr>`, sorted. Marks
* are not driven by the node OVERRIDES table (they are fuzzed by the text
* generator, text-arbitraries.ts), so their value-fuzz coverage is tracked with a
* separate snapshot (see flat-roundtrip.property.test.ts) — without this the
* "no invisible coverage hole" guarantee would hold for node attrs only, letting a
* new mark attr slip through unfuzzed and unallowlisted.
*/
export function allSchemaMarkAttrKeys(): string[] {
const keys: string[] = [];
for (const [name, mark] of Object.entries(schema.marks)) {
const attrs = (mark.spec?.attrs ?? {}) as Record<string, unknown>;
for (const attr of Object.keys(attrs)) keys.push(`mark:${name}.${attr}`);
}
return keys.sort();
}
export type AttrMode = 'p1' | 'fuzz';
/**
* Build an arbitrary for ONE attribute's value (or the ABSENT sentinel) across
* the states legal for `mode`:
* - p1 : absent / default / nonDefault (the round-trip-safe space).
* - fuzz : the above PLUS degenerate (P2 tolerates the one-time
* normalization; P3 only needs totality).
*/
export function attrValueArb(
type: string,
attr: string,
mode: AttrMode,
): fc.Arbitrary<unknown | typeof ABSENT> {
const def = schemaAttrDefaults(type)[attr];
const p = policyFor(type, attr, def);
const states: fc.Arbitrary<unknown | typeof ABSENT>[] = [];
if (!p.always && !p.noDefault) states.push(fc.constant(ABSENT));
if (!p.noDefault) states.push(fc.constant(def));
if (!p.frozen && p.arb) states.push(p.arb);
if (mode === 'fuzz' && !p.frozen && p.degen !== undefined) {
states.push(fc.constant(p.degen));
}
if (states.length === 0) states.push(fc.constant(def));
return fc.oneof(...states);
}
/**
* Build an arbitrary for a node's full `attrs` object over all schema attrs.
* `base` pins caller-required attrs (e.g. a concrete `src`) verbatim; any attr
* present in `base` is NOT re-generated. Omitted (ABSENT) attrs are dropped.
*/
export function nodeAttrsArb(
type: string,
mode: AttrMode,
base: Record<string, unknown> = {},
): fc.Arbitrary<Record<string, unknown>> {
const names = schemaAttrNames(type).filter((n) => !(n in base) && n !== 'id');
if (names.length === 0) return fc.constant({ ...base });
return fc
.tuple(...names.map((n) => attrValueArb(type, n, mode)))
.map((vals) => {
const attrs: Record<string, unknown> = { ...base };
names.forEach((n, i) => {
if (vals[i] !== ABSENT) attrs[n] = vals[i];
});
return attrs;
});
}
@@ -0,0 +1,74 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { convertProseMirrorToMarkdown } from '../../src/lib/markdown-converter.js';
import { markdownToProseMirror } from '../../src/lib/markdown-to-prosemirror.js';
import { docsCanonicallyEqual } from '../../src/lib/canonicalize.js';
// ---------------------------------------------------------------------------
// #351 committed counterexamples — REAL round-trip bugs surfaced by the flat
// generative probing (attribute level). Each is pinned here as an `it.fails`
// (vitest passes ONLY WHILE the assertion still fails), so that the day the
// underlying src/ bug is fixed, the `it.fails` starts PASSING and vitest turns
// this test RED — forcing us to delete the counterexample and (per the epic
// guardrail) tighten the generator. A bare `it.fails` would ship silent
// corruption, so every case below carries a loud `// BUG #351:` explanation.
//
// These bugs are NOT worked around by weakening any property: the offending
// attribute is kept OUT of the P1/P2 generators (documented in
// attr-arbitraries.ts), and the exact failing document lives here as the
// regression pin. FIXING the bug is a separate, maintainer-approved src/ change.
// ---------------------------------------------------------------------------
const here = path.dirname(fileURLToPath(import.meta.url));
const fixtureDir = path.resolve(here, '../fixtures/counterexamples');
function loadDoc(file: string): any {
return JSON.parse(readFileSync(path.join(fixtureDir, file), 'utf8')).doc;
}
describe('#351 counterexamples (known round-trip bugs, pinned as it.fails)', () => {
// BUG #351: a `column` with a PERCENTAGE width ("50%") is not byte-stable.
// The column schema parses `data-width` with parseFloat, dropping the '%':
// md1 = '...data-width="50%"...' (first export)
// re-import stores width = 50 (number)
// md2 = '...data-width="50"...' (second export) => md2 !== md1
// A permanent GS-EDIT-REVERT churn on every git-sync pull. The editor stores
// column widths as percentages, so this is a genuine defect. The fix is in
// src/lib/docmost-schema.ts (column.width parseHTML must preserve the unit)
// and is out of scope for this test-only PR.
it.fails('column percentage width is byte-stable (P2)', async () => {
const doc = loadDoc('columns-column-width-percent.json');
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
// This assertion currently FAILS (md2 drops the '%'), which is exactly what
// `it.fails` expects. When the schema is fixed, it will PASS and flip this
// test red — our cue to remove the pin.
expect(md2).toBe(md1);
});
// BUG #351: an `orderedList` with a non-1 `start` loses its start number.
// CommonMark CAN express this ("5." starts the list at 5), but the converter
// always emits "1." and ignores `attrs.start` (markdown-converter.ts renders
// `${index + 1}.`; the <ol> HTML path also omits `start`):
// doc.start = 5 -> md1 = "1. alpha" (start dropped on export)
// re-import stores start = 1 => docsCanonicallyEqual(rt, doc) === false
// This is a P1 (semantic round-trip) loss of the SAME class as column.width:
// representable in markdown, silently dropped by the converter. It is pinned
// here as the LOUD counterexample rather than being masked as an "accepted
// normalization" in the generator — per the epic guardrail, deciding
// accept-vs-fix for a markdown-representable loss is a MAINTAINER call, so this
// stays a visible known-bug until the maintainer rules on it. The fix would be
// in src/lib/markdown-converter.ts (emit the start number on the first item)
// and is out of scope for this test-only PR.
it.fails('ordered list start number is preserved (P1)', async () => {
const doc = loadDoc('ordered-list-start.json');
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
// Currently FAILS: doc2.start === 1 while doc.start === 5. When the converter
// preserves `start`, this PASSES and flips the test red — remove the pin then.
expect(docsCanonicallyEqual(doc2, doc)).toBe(true);
});
});
@@ -0,0 +1,284 @@
import { describe, expect, it, vi } from 'vitest';
import fc from 'fast-check';
// Real converter, imported the same way the sibling property test does.
import { convertProseMirrorToMarkdown } from '../../src/lib/markdown-converter.js';
// Importing markdownToProseMirror mutates the global DOM via jsdom at module
// load (expected, required for @tiptap/html's generateJSON under Node).
import { markdownToProseMirror } from '../../src/lib/markdown-to-prosemirror.js';
import { docsCanonicallyEqual, canonicalizeContent } from '../../src/lib/index.js';
import { firstDivergence } from '../roundtrip-helpers.js';
import {
schema,
allSchemaAttrKeys,
allSchemaMarkAttrKeys,
attrIsValueFuzzed,
} from './attr-arbitraries.js';
import {
buildGenerators,
coveredTypes,
KNOWN_UNCOVERED,
} from './node-generators.js';
// ── Attribute-value coverage allowlist ──────────────────────────────────────
// The node/mark completeness contract guarantees every TYPE is generated, but
// NOT that every attribute is exercised at a NON-DEFAULT value. An attribute
// with no `arb` in attr-arbitraries.ts is only ever tested at absent/default —
// an INVISIBLE coverage hole (the reviewer's concern). This allowlist makes that
// hole EXPLICIT: it is the exact set of attrs deliberately not value-fuzzed, so
// a NEW attribute (or a newly-frozen one) that lands in this bucket flips the
// snapshot test red and forces a reviewer to classify it. Each belongs to one of:
// - internal/opaque ids & placeholders (attachmentId, slugId, placeholder,
// creatorId, anchorId) — no meaningful non-default to assert;
// - dimensions/among the media family with no standalone md form here
// (aspectRatio, size, caption, drawio/excalidraw/pdf/video/youtube w/h/align)
// — round-trip candidates deferred to a later PR, not silently dropped;
// - ACCEPTED limitations with no md representation (indent, callout.icon,
// orderedList.type, table spans/bg/colwidth);
// - PINNED bugs (column.width, orderedList.start) tracked in
// counterexamples.test.ts.
const ATTR_VALUE_FUZZ_ALLOWLIST = new Set<string>([
'attachment.attachmentId', 'attachment.mime', 'attachment.placeholder', 'attachment.size',
'audio.attachmentId', 'audio.placeholder', 'audio.size',
'callout.icon', 'column.width',
'drawio.align', 'drawio.alt', 'drawio.aspectRatio', 'drawio.attachmentId',
'drawio.height', 'drawio.size', 'drawio.title', 'drawio.width',
'embed.align', 'embed.height', 'embed.width',
'excalidraw.align', 'excalidraw.alt', 'excalidraw.aspectRatio', 'excalidraw.attachmentId',
'excalidraw.height', 'excalidraw.size', 'excalidraw.title', 'excalidraw.width',
'heading.indent',
'image.aspectRatio', 'image.attachmentId', 'image.caption', 'image.placeholder', 'image.size',
'mention.anchorId', 'mention.creatorId', 'mention.slugId',
'orderedList.start', 'orderedList.type', 'paragraph.indent',
'pdf.attachmentId', 'pdf.height', 'pdf.placeholder', 'pdf.size', 'pdf.width',
'tableCell.backgroundColor', 'tableCell.backgroundColorName', 'tableCell.colspan',
'tableCell.colwidth', 'tableCell.rowspan',
'tableHeader.backgroundColor', 'tableHeader.backgroundColorName', 'tableHeader.colspan',
'tableHeader.colwidth', 'tableHeader.rowspan',
'video.align', 'video.aspectRatio', 'video.attachmentId', 'video.placeholder', 'video.size',
'youtube.align', 'youtube.height', 'youtube.width',
]);
// ── MARK attribute-value coverage ───────────────────────────────────────────
// Marks are fuzzed by the text generator (text-arbitraries.ts markedTextRunArb),
// not the node OVERRIDES table, so their value-fuzz coverage is tracked with this
// separate registry — otherwise the "no invisible coverage hole" guarantee would
// hold for node attrs only, and a new mark attr (or a new attributed mark) would
// silently escape the fuzz set. Every schema mark attr must be in exactly one of:
// MARK_ATTR_FUZZED — actually driven at a non-default value by the generator;
// MARK_ATTR_ALLOWLIST — deliberately not value-fuzzed, with a reason.
const MARK_ATTR_FUZZED = new Set<string>([
'mark:link.href', // markedTextRunArb sets a random webUrl href
'mark:link.title', // ...and an optional letter-bearing title
'mark:highlight.color', // highlight mark carries a generated color
'mark:textStyle.color', // textStyle mark carries a generated color
'mark:comment.commentId', // comment anchor id (alphanumeric token)
'mark:comment.resolved', // comment resolved flag (rides only when true)
]);
const MARK_ATTR_ALLOWLIST = new Set<string>([
// link presentational/routing attrs: not part of the markdown link surface the
// converter emits (it round-trips href + title only), so there is no
// non-default value to assert here — a deferred concern for a link-specific
// fixture, not the flat generative pass.
'mark:link.internal',
'mark:link.target',
'mark:link.rel',
'mark:link.class',
]);
// Each run does a real convert + marked + jsdom parse (~ms). Give ample headroom
// so the suite is deterministic regardless of parallel worker load (like the
// sibling property file).
vi.setConfig({ testTimeout: 30000 });
// ---------------------------------------------------------------------------
// #351 PR 1 — GENERATIVE (property-based) round-trip over FLAT (single-node)
// documents at the ATTRIBUTE level.
//
// We assert three invariants for ANY generated valid flat document `d`
// (pmToMd = convertProseMirrorToMarkdown, mdToPm = markdownToProseMirror):
//
// P1 — semantic round-trip (nothing lost):
// docsCanonicallyEqual(await mdToPm(pmToMd(d)), d) === true
// P2 — byte fixpoint (anti "GS-EDIT-REVERT" churn):
// pmToMd(await mdToPm(pmToMd(d))) === pmToMd(d)
// P3 — totality: neither converter throws; bounded.
//
// The generators are schema-DERIVED (attribute lists come from
// schema.nodes[type].spec.attrs) and stay inside the round-trip-supported space
// proven empirically by probing the live converter (see attr-arbitraries.ts and
// text-arbitraries.ts). P1 runs over the safe attribute space; P2/P3 run over
// the wider 'fuzz' space that also injects degenerate attribute states, which
// P2 tolerates via a one-time first-pass normalization and P3 via totality only.
// ---------------------------------------------------------------------------
// Fixed seed so every failure is reproducible; fast-check also prints the
// shrunk counterexample. numRuns starts modest to keep CI under budget — the
// issue's CI target is ~300-500 per property; the nightly / PR 3 will crank
// this up further. Each property runs over the UNION (fc.oneof) of all flat
// node generators, so the runs are shared across node types (one test per
// property keeps the jsdom import cost and memory bounded — a per-generator ×
// per-property matrix is ~200 heavy tests that OOMs the worker).
const SEED = 20250705;
const NUM_RUNS = 300;
const P1_GENERATORS = buildGenerators('p1');
const FUZZ_GENERATORS = buildGenerators('fuzz');
// Union arbitraries: a single draw picks one node generator, then a document
// from it. On failure fast-check prints the shrunk counterexample doc, which
// names the offending node type directly.
const p1Union = fc.oneof(...P1_GENERATORS.map((g) => g.arb));
const fuzzUnion = fc.oneof(...FUZZ_GENERATORS.map((g) => g.arb));
async function roundTrip(doc: unknown): Promise<{ md1: string; md2: string; doc2: any }> {
const md1 = convertProseMirrorToMarkdown(doc);
const doc2 = await markdownToProseMirror(md1);
const md2 = convertProseMirrorToMarkdown(doc2);
return { md1, md2, doc2 };
}
describe('#351 flat generative round-trip — completeness contract', () => {
it('every schema node and mark is covered by a generator or explicitly allowlisted', () => {
const covered = coveredTypes();
const uncovered: string[] = [];
for (const nodeType of Object.keys(schema.nodes)) {
if (covered.has(nodeType)) continue;
if (nodeType in KNOWN_UNCOVERED) continue;
uncovered.push(`node:${nodeType}`);
}
for (const markType of Object.keys(schema.marks)) {
if (covered.has(`mark:${markType}`)) continue;
if (markType in KNOWN_UNCOVERED) continue;
uncovered.push(`mark:${markType}`);
}
// A new node/mark added to the schema with no generator AND no allowlist
// entry MUST turn this test red — that is the whole point (no silent blind
// spots).
expect(
uncovered,
`these schema types have no generator and no KNOWN_UNCOVERED reason:\n ${uncovered.join(
'\n ',
)}`,
).toEqual([]);
});
it('every KNOWN_UNCOVERED entry is a real schema type (no stale allowlist rows)', () => {
const all = new Set([...Object.keys(schema.nodes), ...Object.keys(schema.marks)]);
for (const t of Object.keys(KNOWN_UNCOVERED)) {
expect(all.has(t), `stale KNOWN_UNCOVERED entry: ${t}`).toBe(true);
}
});
it('every attribute is value-fuzzed OR explicitly allowlisted (no invisible hole)', () => {
// Makes the "generic-frozen" coverage hole VISIBLE: any schema attr not
// exercised at a non-default value must be a KNOWN entry in the allowlist.
// A new attr (or one that loses its `arb`) that falls into the not-fuzzed
// bucket without an allowlist row turns this red — no silent blind spots.
const unaccounted: string[] = [];
for (const key of allSchemaAttrKeys()) {
const i = key.indexOf('.');
const fuzzed = attrIsValueFuzzed(key.slice(0, i), key.slice(i + 1));
if (!fuzzed && !ATTR_VALUE_FUZZ_ALLOWLIST.has(key)) unaccounted.push(key);
}
expect(
unaccounted,
`these attrs are not value-fuzzed and not in ATTR_VALUE_FUZZ_ALLOWLIST:\n ${unaccounted.join(
'\n ',
)}`,
).toEqual([]);
});
it('the attribute allowlist has no stale rows (every entry is really not-fuzzed)', () => {
const notFuzzed = new Set(
allSchemaAttrKeys().filter((key) => {
const i = key.indexOf('.');
return !attrIsValueFuzzed(key.slice(0, i), key.slice(i + 1));
}),
);
for (const key of ATTR_VALUE_FUZZ_ALLOWLIST) {
expect(
notFuzzed.has(key),
`stale allowlist row (attr is now value-fuzzed, remove it): ${key}`,
).toBe(true);
}
});
it('every MARK attribute is value-fuzzed OR allowlisted (no invisible hole)', () => {
// The node guard above covers node attrs; marks are fuzzed by the text
// generator, so their coverage is tracked separately. A new mark attr (or a
// newly-attributed mark) that lands in neither set turns this red.
const unaccounted: string[] = [];
for (const key of allSchemaMarkAttrKeys()) {
if (!MARK_ATTR_FUZZED.has(key) && !MARK_ATTR_ALLOWLIST.has(key)) {
unaccounted.push(key);
}
}
expect(
unaccounted,
`these mark attrs are neither in MARK_ATTR_FUZZED nor MARK_ATTR_ALLOWLIST:\n ${unaccounted.join(
'\n ',
)}`,
).toEqual([]);
});
it('the MARK fuzz/allowlist sets have no stale rows (every entry is a real schema mark attr)', () => {
const all = new Set(allSchemaMarkAttrKeys());
for (const key of [...MARK_ATTR_FUZZED, ...MARK_ATTR_ALLOWLIST]) {
expect(all.has(key), `stale mark-attr registry row: ${key}`).toBe(true);
}
});
});
describe('#351 flat generative round-trip — properties', () => {
it('generator validity: every generated doc passes schema.check()', () => {
// A generator that emits an invalid ProseMirror document is a GENERATOR bug.
fc.assert(
fc.property(fuzzUnion, (doc) => {
schema.nodeFromJSON(doc).check(); // throws on an invalid doc
return true;
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it('P1 — semantic round-trip: docsCanonicallyEqual(mdToPm(pmToMd(d)), d)', async () => {
await fc.assert(
fc.asyncProperty(p1Union, async (doc) => {
const { doc2 } = await roundTrip(doc);
if (!docsCanonicallyEqual(doc2, doc)) {
// Surface the precise divergence in the failure message.
const div = firstDivergence(
JSON.parse(JSON.stringify(canonicalizeContent(doc2))),
JSON.parse(JSON.stringify(canonicalizeContent(doc))),
);
throw new Error(
`P1 divergence @ ${div?.path}: got=${JSON.stringify(div?.a)} want=${JSON.stringify(div?.b)}`,
);
}
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it('P2 — byte fixpoint: pmToMd(mdToPm(pmToMd(d))) === pmToMd(d)', async () => {
await fc.assert(
fc.asyncProperty(fuzzUnion, async (doc) => {
const { md1, md2 } = await roundTrip(doc);
expect(md2).toBe(md1);
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
it('P3 — totality: neither converter throws', async () => {
await fc.assert(
fc.asyncProperty(fuzzUnion, async (doc) => {
// Throwing here fails the property; fast-check shrinks to a minimal doc.
await roundTrip(doc);
}),
{ numRuns: NUM_RUNS, seed: SEED },
);
});
});
@@ -0,0 +1,310 @@
/**
* Flat single-node document generators (#351, PR 1).
*
* For every schema node type that can stand alone, a fast-check arbitrary
* producing `{ type:'doc', content:[ <the target node> ] }` with generated attrs
* (via nodeAttrsArb) and the minimal REQUIRED immediate children the schema
* demands (a heading's inline text, a listItem's one paragraph, a table's
* minimal rows, details' summary+content, a callout's one paragraph). Kept
* FLAT: a single target node, no deep nesting — nested structural generation is
* PR 2.
*
* The `mode` threads through to the attribute arbitraries:
* - 'p1' : the round-trip-safe attribute space (P1 semantic round-trip).
* - 'fuzz' : adds degenerate attribute states (P2 byte-fixpoint tolerates the
* one-time normalization; P3 only needs totality).
*
* A COMPLETENESS CONTRACT (see flat-roundtrip.property.test.ts) enumerates the
* whole schema and asserts every node/mark is EITHER produced by a generator
* here OR listed in KNOWN_UNCOVERED with a reason — so a new schema type with no
* generator turns the suite RED.
*/
import fc from 'fast-check';
import { type AttrMode, nodeAttrsArb } from './attr-arbitraries.js';
import {
inlineContentArb,
headingInlineContentArb,
plainInlineContentArb,
phraseArb,
markedTextRunArb,
} from './text-arbitraries.js';
const doc = (node: any) => ({ type: 'doc', content: [node] });
const para = (content: any[]) => ({ type: 'paragraph', content });
/** A named flat-document generator. */
export interface NamedGen {
name: string;
arb: fc.Arbitrary<any>;
}
// ---------------------------------------------------------------------------
// Per-target generators, each a function of mode.
// ---------------------------------------------------------------------------
const gen = {
paragraph: (m: AttrMode) =>
fc.tuple(nodeAttrsArb('paragraph', m), inlineContentArb).map(([attrs, content]) =>
doc({ type: 'paragraph', attrs, content }),
),
heading: (m: AttrMode) =>
fc.tuple(nodeAttrsArb('heading', m), headingInlineContentArb).map(([attrs, content]) =>
doc({ type: 'heading', attrs, content }),
),
blockquote: (_m: AttrMode) =>
inlineContentArb.map((content) => doc({ type: 'blockquote', content: [para(content)] })),
bulletList: (_m: AttrMode) =>
fc
.array(inlineContentArb, { minLength: 1, maxLength: 3 })
.map((items) =>
doc({
type: 'bulletList',
content: items.map((c) => ({ type: 'listItem', content: [para(c)] })),
}),
),
orderedList: (m: AttrMode) =>
fc
.tuple(nodeAttrsArb('orderedList', m), fc.array(inlineContentArb, { minLength: 1, maxLength: 3 }))
.map(([attrs, items]) =>
doc({
type: 'orderedList',
attrs,
content: items.map((c) => ({ type: 'listItem', content: [para(c)] })),
}),
),
taskList: (m: AttrMode) =>
fc
.array(fc.tuple(nodeAttrsArb('taskItem', m), inlineContentArb), { minLength: 1, maxLength: 3 })
.map((items) =>
doc({
type: 'taskList',
content: items.map(([attrs, c]) => ({ type: 'taskItem', attrs, content: [para(c)] })),
}),
),
codeBlock: (m: AttrMode) =>
fc
.tuple(
nodeAttrsArb('codeBlock', m),
// A fenced code block always re-imports with a TRAILING NEWLINE in its
// text (empirically confirmed). Author the newline so the doc is already
// at the round-trip fixpoint (supported-space shaping, not a masked bug).
fc.array(phraseArb, { minLength: 1, maxLength: 3 }).map((lines) => lines.join('\n') + '\n'),
)
.map(([attrs, code]) =>
doc({ type: 'codeBlock', attrs, content: [{ type: 'text', text: code }] }),
),
horizontalRule: (_m: AttrMode) => fc.constant(doc({ type: 'horizontalRule' })),
pageBreak: (_m: AttrMode) => fc.constant(doc({ type: 'pageBreak' })),
image: (m: AttrMode) => nodeAttrsArb('image', m).map((attrs) => doc({ type: 'image', attrs })),
callout: (m: AttrMode) =>
fc.tuple(nodeAttrsArb('callout', m), inlineContentArb).map(([attrs, content]) =>
doc({ type: 'callout', attrs, content: [para(content)] }),
),
mathBlock: (m: AttrMode) =>
nodeAttrsArb('mathBlock', m).map((attrs) => doc({ type: 'mathBlock', attrs })),
details: (m: AttrMode) =>
fc
.tuple(nodeAttrsArb('details', m), plainInlineContentArb, inlineContentArb)
.map(([attrs, summary, body]) =>
doc({
type: 'details',
attrs,
content: [
{ type: 'detailsSummary', content: summary },
{ type: 'detailsContent', content: [para(body)] },
],
}),
),
table: (_m: AttrMode) =>
fc.integer({ min: 1, max: 3 }).chain((cols) => {
// GFM alignment is column-wide (encoded in the header separator), so a
// column's alignment must be identical on the header and every body cell,
// else the second export re-aligns and churns. Pick ONE align per column.
const alignsArb = fc.array(fc.constantFrom(undefined, 'left', 'center', 'right'), {
minLength: cols,
maxLength: cols,
});
const cell = (header: boolean, align?: string) =>
phraseArb.map((t) => ({
type: header ? 'tableHeader' : 'tableCell',
// colspan/rowspan pinned to 1 (GFM cannot express spans); optional
// column-consistent align.
attrs: { colspan: 1, rowspan: 1, ...(align ? { align } : {}) },
content: [para([{ type: 'text', text: t }])],
}));
return alignsArb.chain((aligns) => {
const headerRow = fc
.tuple(...aligns.map((a) => cell(true, a)))
.map((cells) => ({ type: 'tableRow', content: cells }));
const bodyRow = fc
.tuple(...aligns.map((a) => cell(false, a)))
.map((cells) => ({ type: 'tableRow', content: cells }));
return fc
.tuple(headerRow, fc.array(bodyRow, { minLength: 1, maxLength: 2 }))
.map(([h, body]) => doc({ type: 'table', content: [h, ...body] }));
});
}),
columns: (m: AttrMode) =>
// Couple the column count to the layout so the two stay consistent
// (two_equal/left_sidebar/right_sidebar -> 2, three_equal -> 3).
fc
.constantFrom('two_equal', 'three_equal', 'left_sidebar', 'right_sidebar')
.chain((layout) => {
const count = layout === 'three_equal' ? 3 : 2;
return fc
.tuple(
nodeAttrsArb('columns', m, { layout, widthMode: 'normal' }),
fc.array(inlineContentArb, { minLength: count, maxLength: count }),
)
.map(([attrs, bodies]) =>
doc({
type: 'columns',
attrs,
content: bodies.map((c) => ({ type: 'column', content: [para(c)] })),
}),
);
}),
subpages: (m: AttrMode) =>
nodeAttrsArb('subpages', m).map((attrs) => doc({ type: 'subpages', attrs })),
audio: (m: AttrMode) => nodeAttrsArb('audio', m).map((attrs) => doc({ type: 'audio', attrs })),
video: (m: AttrMode) => nodeAttrsArb('video', m).map((attrs) => doc({ type: 'video', attrs })),
pdf: (m: AttrMode) => nodeAttrsArb('pdf', m).map((attrs) => doc({ type: 'pdf', attrs })),
youtube: (m: AttrMode) => nodeAttrsArb('youtube', m).map((attrs) => doc({ type: 'youtube', attrs })),
embed: (m: AttrMode) => nodeAttrsArb('embed', m).map((attrs) => doc({ type: 'embed', attrs })),
drawio: (m: AttrMode) => nodeAttrsArb('drawio', m).map((attrs) => doc({ type: 'drawio', attrs })),
excalidraw: (m: AttrMode) =>
nodeAttrsArb('excalidraw', m).map((attrs) => doc({ type: 'excalidraw', attrs })),
attachment: (m: AttrMode) =>
nodeAttrsArb('attachment', m).map((attrs) => doc({ type: 'attachment', attrs })),
htmlEmbed: (m: AttrMode) =>
nodeAttrsArb('htmlEmbed', m).map((attrs) => doc({ type: 'htmlEmbed', attrs })),
pageEmbed: (m: AttrMode) =>
nodeAttrsArb('pageEmbed', m).map((attrs) => doc({ type: 'pageEmbed', attrs })),
transclusionReference: (m: AttrMode) =>
nodeAttrsArb('transclusionReference', m).map((attrs) =>
doc({ type: 'transclusionReference', attrs }),
),
transclusionSource: (m: AttrMode) =>
fc.tuple(nodeAttrsArb('transclusionSource', m), inlineContentArb).map(([attrs, content]) =>
doc({ type: 'transclusionSource', attrs, content: [para(content)] }),
),
// A footnote reference PLUS its definition (the reference has no standalone
// markdown form without its definition — see KNOWN_UNCOVERED note for the
// bare reference). Both carry the same id. The definition body uses
// headingInlineContentArb (NO hard breaks): a footnote is serialized inline as
// `^[...]`, so a hard break inside it collapses to a single space on re-parse
// (empirically confirmed) — that is the container's markdown limitation, not
// an attribute-level concern. The reference-bearing paragraph is a NORMAL
// paragraph and keeps the full inline corpus.
footnotes: (m: AttrMode) =>
fc.tuple(fc.constantFrom('fn1', 'fn2', 'note'), inlineContentArb, headingInlineContentArb).map(
([id, refText, noteBody]) => ({
type: 'doc',
content: [
para([...refText, { type: 'footnoteReference', attrs: { id } }]),
{
type: 'footnotesList',
content: [{ type: 'footnoteDefinition', attrs: { id }, content: [para(noteBody)] }],
},
],
}),
),
// ── inline targets wrapped in a paragraph ────────────────────────────────
mention: (m: AttrMode) =>
nodeAttrsArb('mention', m).map((attrs) => doc(para([{ type: 'mention', attrs }]))),
mathInline: (m: AttrMode) =>
fc.tuple(phraseArb, nodeAttrsArb('mathInline', m)).map(([t, attrs]) =>
doc(para([{ type: 'text', text: t }, { type: 'mathInline', attrs }])),
),
status: (m: AttrMode) =>
nodeAttrsArb('status', m).map((attrs) => doc(para([{ type: 'status', attrs }]))),
hardBreak: (_m: AttrMode) =>
fc.tuple(phraseArb, phraseArb).map(([a, b]) =>
doc(para([{ type: 'text', text: a }, { type: 'hardBreak' }, { type: 'text', text: b }])),
),
// ── marks: a paragraph of marked runs (covers every mark type) ───────────
marksOnText: (_m: AttrMode) =>
fc.array(markedTextRunArb, { minLength: 1, maxLength: 5 }).map((runs) => {
// Merge adjacent same-mark runs (see text-arbitraries.normalizeInline).
const out: any[] = [];
for (const r of runs) {
const prev = out[out.length - 1];
if (prev && JSON.stringify(prev.marks ?? []) === JSON.stringify(r.marks ?? [])) {
prev.text += r.text;
} else out.push({ ...r });
}
return doc(para(out));
}),
};
/** Build the full list of named generators for a given mode. */
export function buildGenerators(mode: AttrMode): NamedGen[] {
return Object.entries(gen).map(([name, f]) => ({ name, arb: f(mode) }));
}
// ---------------------------------------------------------------------------
// Completeness contract support.
// ---------------------------------------------------------------------------
/**
* Schema node/mark types deliberately NOT covered by a P1/P2 generator, each
* with a one-line reason. Excluding a type means it is kept OUT of the round-
* trip generators — it does NOT weaken any property.
*
* NOTE (empirical): the candidates the issue flagged for review — pageEmbed,
* subpages, transclusionSource/Reference, mention, status — were PROBED against
* the live converter and DO round-trip P1/P2 with placeholder ids, so they are
* COVERED by real generators rather than allowlisted here. The allowlist below
* holds only types with no standalone flat generator by construction.
*/
export const KNOWN_UNCOVERED: Record<string, string> = {
// The root node; it is the wrapper every generated doc already is, never a
// "target" content node, so it has no standalone generator of its own.
doc: 'the document root wrapper, not a content node with a standalone generator',
};
/** Recursively collect every node type and `mark:<type>` under a tree. */
export function collectTypes(node: any, seen = new Set<string>()): Set<string> {
if (!node || typeof node !== 'object') return seen;
if (node.type) seen.add(node.type);
for (const m of node.marks ?? []) if (m?.type) seen.add(`mark:${m.type}`);
for (const c of node.content ?? []) collectTypes(c, seen);
return seen;
}
/**
* Sample every generator and return the union of node/mark types they produce.
* Deterministic (fixed seed) so the completeness contract is stable.
*/
export function coveredTypes(seed = 12345, perGen = 60): Set<string> {
const seen = new Set<string>();
for (const { arb } of buildGenerators('p1')) {
for (const sample of fc.sample(arb, { numRuns: perGen, seed })) {
collectTypes(sample, seen);
}
}
return seen;
}
@@ -0,0 +1,258 @@
/**
* Hostile inline-text corpus for the generative flat-document round-trip suite
* (#351, PR 1).
*
* These arbitraries are a DIRECT PORT of the "supported space" guardrails that
* `test/markdown-roundtrip.property.test.ts` proved empirically against the live
* converter. That file's long header documents WHY each guardrail exists; rather
* than re-derive them, we reuse the exact same shapes here so the attribute-level
* generative suite inherits the same byte-stable text space. Each guardrail is
* cited back to that file below.
*
* The corpus deliberately spans the CommonMark / canon hostile alphabet
* (`* _ [ ] ( ) { } | < > & # ! ~ = + -`), unicode / emoji / RTL, and the legal
* mark combinations on runs (including the `code` mark, which the schema's
* `excludes: "_"` makes suppress every co-occurring mark — so it is never
* combined with another mark in the byte-stable space).
*/
import fc from 'fast-check';
// ---------------------------------------------------------------------------
// Words and the hostile special-character alphabet.
// (Ported from markdown-roundtrip.property.test.ts, "Inline text arbitraries".)
// ---------------------------------------------------------------------------
/** Alphanumeric "word" (no markdown-significant characters). Length 1..6. */
export const wordArb = fc
.stringMatching(/^[A-Za-z0-9]{1,6}$/)
.filter((w) => w.length > 0);
/**
* A SINGLE markdown-significant character, emitted only as an isolated,
* space-flanked token. Every char the task calls out plus a few more; each was
* verified byte-stable in this position by the sibling property test.
*
* NOTE: the backtick (`) is DELIBERATELY excluded from free-floating plain text
* (it is a code-span delimiter that re-pairs globally). It is exercised only via
* the `code` mark and code blocks — see markdown-roundtrip.property.test.ts.
*/
export const specialCharArb = fc.constantFrom(
'*', '_', '[', ']', '(', ')', '{', '}', '|', '<', '>', '&', '#', '!', '~', '=', '+', '-',
);
// A pinch of unicode / emoji / RTL, always word-like (no markdown specials) so
// it stays inside the space-flanked corpus. Kept letter/emoji-bearing so it is
// never coerced to a number (see letterPhraseArb rationale).
export const unicodeWordArb = fc.constantFrom(
'café', 'naïve', 'Zürich', 'Москва', 'こんにちは', '你好', '😀', '🚀x', 'مرحبا', 'שלום',
);
/**
* A "safe special" text string: a space-joined sequence of tokens that always
* BEGINS and ENDS with an alphanumeric word, with any isolated special chars (or
* unicode words) confined to the MIDDLE, each space-flanked by words.
*
* Both boundary guarantees matter (verbatim from the sibling test):
* * Leading word: the line never opens with a block/inline trigger
* (">", "*", "-", "#", "1." ...).
* * Trailing word: adjacent text runs CONCATENATE with no separator, so a run
* ending in a bare "<" beside a run starting with a letter would form a fake
* HTML tag. Ending every run with a word keeps every special internal and
* space-flanked even after concatenation.
*/
export const safeTextArb: fc.Arbitrary<string> = fc
.tuple(
wordArb,
fc.array(fc.oneof(wordArb, specialCharArb, unicodeWordArb), {
minLength: 0,
maxLength: 3,
}),
wordArb,
)
.map(([first, middle, last]) => [first, ...middle, last].join(' '));
/**
* A plain alphanumeric phrase (1..3 words) for places where even isolated
* specials are not wanted (e.g. code-block language, mention labels, status
* text, table cells rendered on the plain-markdown path).
*/
export const phraseArb: fc.Arbitrary<string> = fc
.array(wordArb, { minLength: 1, maxLength: 3 })
.map((ws) => ws.join(' '));
/**
* A phrase guaranteed to contain at least one letter. Used for image/media alt
* text and link titles: a PURELY numeric alt/title (e.g. "0") is parsed back as
* a NUMBER and then dropped by the converter's `value || ""` coercion — not
* byte-stable. A letter anywhere keeps it a string. (Ported verbatim.)
*/
export const letterPhraseArb: fc.Arbitrary<string> = fc
.tuple(
fc.stringMatching(/^[A-Za-z]{1,4}$/),
fc.array(wordArb, { minLength: 0, maxLength: 2 }),
)
.map(([head, rest]) => [head, ...rest].join(' '));
/** A paren/space-free URL — safe inside markdown link/image `(...)` syntax. */
export const urlArb: fc.Arbitrary<string> = fc
.webUrl()
.filter((u) => !/[()\s]/.test(u));
// ---------------------------------------------------------------------------
// Marked inline runs.
// (Ported from markdown-roundtrip.property.test.ts "markedTextRunArb".)
// ---------------------------------------------------------------------------
/**
* A text run with an OPTIONAL single non-code formatting mark (bold/italic/
* strike/underline/superscript/subscript/spoiler), or a SOLE `code` mark, or a
* link, or an inline comment anchor. `code` is NEVER combined with another mark
* in the byte-stable space (that combination is a documented converter
* limitation — the schema's `code` mark declares `excludes: "_"`). Marks wrap
* `safeTextArb`, which stays stable even when it contains isolated specials.
*
* The mark set here is broadened past the sibling test's {bold,italic,strike}
* to also cover underline / superscript / subscript / spoiler / textStyle /
* highlight (all single, non-code marks), so the marks-on-text generator
* exercises every mark the schema declares except the deliberately-excluded
* `code`+other combination.
*/
export const markedTextRunArb: fc.Arbitrary<any> = fc.oneof(
// Plain text.
safeTextArb.map((t) => ({ type: 'text', text: t })),
// Single formatting mark (attribute-free marks).
fc
.tuple(
safeTextArb,
fc.constantFrom('bold', 'italic', 'strike', 'underline', 'superscript', 'subscript', 'spoiler'),
)
.map(([t, m]) => ({ type: 'text', text: t, marks: [{ type: m }] })),
// highlight with a color attr.
fc
.tuple(safeTextArb, fc.constantFrom('#ffcc00', '#a0e0ff', 'yellow'))
.map(([t, color]) => ({ type: 'text', text: t, marks: [{ type: 'highlight', attrs: { color } }] })),
// textStyle with a color attr.
fc
.tuple(safeTextArb, fc.constantFrom('#123456', '#ff0000', '#00aa88'))
.map(([t, color]) => ({ type: 'text', text: t, marks: [{ type: 'textStyle', attrs: { color } }] })),
// Sole code mark (backtick span). safeTextArb is backtick-free, so the span
// content cannot contain an inner backtick.
safeTextArb.map((t) => ({ type: 'text', text: t, marks: [{ type: 'code' }] })),
// Link with safe text, a paren/space-free href, optionally a letter-bearing
// title (a purely numeric title is coerced to a number and dropped).
fc
.tuple(phraseArb, urlArb, fc.option(letterPhraseArb, { nil: undefined }))
.map(([t, href, title]) => ({
type: 'text',
text: t,
marks: [{ type: 'link', attrs: title ? { href, title } : { href } }],
})),
// Inline comment anchor: a span[data-comment-id] that must survive byte-for-
// byte. commentId is an alphanumeric token; `resolved` rides only when true.
fc
.tuple(safeTextArb, fc.stringMatching(/^[A-Za-z0-9]{4,10}$/), fc.boolean())
.map(([t, commentId, resolved]) => ({
type: 'text',
text: t,
marks: [
{ type: 'comment', attrs: resolved ? { commentId, resolved: true } : { commentId } },
],
})),
);
// ---------------------------------------------------------------------------
// Inline atoms and inline-content assembly.
// (Ported from markdown-roundtrip.property.test.ts.)
// ---------------------------------------------------------------------------
/** Inline math node carrying LaTeX that includes the `a < b` the task asks for. */
export const mathInlineArb: fc.Arbitrary<any> = fc
.constantFrom('a < b', 'x^2 + y^2', 'a < b < c', '\\frac{1}{2}', 'E = mc^2')
.map((text) => ({ type: 'mathInline', attrs: { text } }));
/** Mention node; label/id/entity are plain phrases / uuids. */
export const mentionArb: fc.Arbitrary<any> = fc
.tuple(phraseArb, fc.uuid(), fc.uuid())
.map(([label, id, entityId]) => ({
type: 'mention',
attrs: { id, label, entityType: 'user', entityId },
}));
export const hardBreakArb: fc.Arbitrary<any> = fc.constant({ type: 'hardBreak' });
const sameMarks = (a: any[] | undefined, b: any[] | undefined): boolean =>
JSON.stringify(a ?? []) === JSON.stringify(b ?? []);
/**
* Canonicalize a generated inline-content array the way ProseMirror stores it,
* then trim the markdown-fragile edges. (Ported verbatim from
* markdown-roundtrip.property.test.ts "normalizeInline":)
* 1) MERGE adjacent text runs with IDENTICAL marks (the editor coalesces
* them; split same-mark runs export to ambiguous "**a****b**").
* 2) Collapse CONSECUTIVE hard breaks (two render a blank line marked eats).
* 3) Drop a TRAILING hard break (removed by the converter's .trim()).
*/
export function normalizeInline(nodes: any[]): any[] {
const out: any[] = [];
for (const node of nodes) {
const prev = out[out.length - 1];
if (node.type === 'hardBreak' && prev && prev.type === 'hardBreak') continue;
if (
node.type === 'text' &&
prev &&
prev.type === 'text' &&
sameMarks(prev.marks, node.marks)
) {
prev.text += node.text;
continue;
}
out.push(node.type === 'text' ? { ...node } : node);
}
while (out.length > 1 && out[out.length - 1].type === 'hardBreak') out.pop();
return out;
}
/**
* Inline content for a paragraph: at least one marked text run, optionally with
* inline atoms (math/mention) and hard breaks interspersed. Always starts with a
* text run so the paragraph never opens with a block trigger. (Ported.)
*/
export const inlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
markedTextRunArb,
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: mathInlineArb },
{ weight: 1, arbitrary: mentionArb },
{ weight: 1, arbitrary: hardBreakArb },
),
{ minLength: 0, maxLength: 4 },
),
)
.map(([first, rest]) => normalizeInline([first, ...rest]));
/**
* Inline content for a HEADING — identical to a paragraph's, but WITHOUT hard
* breaks. A hard break inside an ATX heading is not byte-stable (marked splits
* the heading). (Ported.)
*/
export const headingInlineContentArb: fc.Arbitrary<any[]> = fc
.tuple(
markedTextRunArb,
fc.array(
fc.oneof(
{ weight: 5, arbitrary: markedTextRunArb },
{ weight: 1, arbitrary: mathInlineArb },
{ weight: 1, arbitrary: mentionArb },
),
{ minLength: 0, maxLength: 4 },
),
)
.map(([first, rest]) => normalizeInline([first, ...rest]));
/** Simple plain-text inline content (single run) for containers rendered on the
* raw-HTML path (table cells / column bodies) where fancy inline is undesirable. */
export const plainInlineContentArb: fc.Arbitrary<any[]> = phraseArb.map((t) => [
{ type: 'text', text: t },
]);