Compare commits

...

7 Commits

Author SHA1 Message Date
agent_coder 4809348457 test(mcp): ассертить collab-hint (pageId + transient/retry) в reject-текстах (ревью #441)
Enrichment collab-ошибок из Phase C (#437) дописывает
'(pageId …; transient — retry once; …)' к connect-timeout/connection-closed,
но reject-регекспы матчили только базовый текст → проходили и С hint, и БЕЗ
(vacuous). Ужесточил два ассерта (connection-closed, connect-timeout) до
'<база> (pageId page-1; transient' — теперь рефактор, убравший hint(), их
роняет. Мутационно: hint()->'' → ровно эти 2 теста краснеют (18->16).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:14:39 +03:00
agent_coder d3d32d637b fix(mcp): no-response диагностика — не отдавать сырой error.message (утечка host в модель) (внутр. ревью #437)
no-response ветка формата использовала error.code ?? error.message: при
отсутствии code axios-сообщения сетевых ошибок содержат host:port
('connect ECONNREFUSED 127.0.0.1:3000', 'getaddrinfo ENOTFOUND host'), что
нарушает инвариант #437 «host никогда не попадает в видимое модели сообщение».
Теперь только error.code (?? 'network error'); полный нативный текст уходит в
stderr под DEBUG. code проставлен фактически для всех реальных no-response
ошибок. Тест обновлён: сырое host-содержащее сообщение -> нейтральный reason,
плюс ассерт что host в сообщении отсутствует.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder 9a435201b8 feat(mcp): enrich collab connect/persist/closed error texts with pageId + retry hint (#437)
Append `(pageId <id>; transient — retry once; persistent failures mean the
collab server is unreachable/overloaded)` to the connect-timeout, persist-timeout
and connection-closed error texts in CollabSession, so the agent can self-correct
instead of blind-looping. The Yjs-encode error is left untouched (it already names
the offending attribute).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
agent_coder d6827b9210 feat(mcp): actionable tool errors — central axios diagnostics + fail-fast comment-id guard (#437)
Phase A: add ONE response interceptor on DocmostClient's axios instance,
registered AFTER the re-login interceptor, that reformats a failed request's
error.message IN PLACE (never a custom Error subclass, so the live
axios.isAxiosError / error.response?.status / config._retry checks keep working)
into `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`, or the
no-response variant. serverMessage is built ONLY from the whitelisted
message/error fields or statusText — raw string/HTML bodies, headers and config
never appear; an arraybuffer body is size-capped JSON.parsed; the full body goes
to stderr only under DEBUG (parity with downloadImage). A _docmostFormatted flag
guards against double-processing.

Phase B (#436): assertFullUuid throws an actionable error BEFORE any network
call at all five commentId sites (resolve/update/delete/get_comment and
create_comment's parentCommentId when provided), so a truncated id can no longer
loop as an opaque 400/404.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:12:37 +03:00
vvzvlad 0108dec0e6 Merge pull request 'feat(mcp): детерминированная нормализация текста сносок + склейка форков' (#422) from feat/419-footnote-normalize into develop
Reviewed-on: #422
2026-07-10 07:08:50 +03:00
agent_coder f750a509c2 fix(mcp): не нормализовать текст под маркой code в сносках (порча code-литералов)
Ревью #422: безусловная нормализация переписывала в ASCII и текст inline-code
внутри сносок (кавычки/тире/спецпробелы) — для code-литерала это не типографика,
а изменение смысла (эмпирически на raw-JSON путях: code-нода "a—b «x»" -> "a-b").
normalizeDefinitionText теперь пропускает текст-ноды с маркой code (verbatim), и
краевой trim определения тоже не трогает крайние code-ноды. footnoteMergeKey
читает сырой текст code-нод -> сноски, различающиеся только глифами в коде, НЕ
сливаются, а форки в прозе по-прежнему сливаются. Тесты: code verbatim +
непослияние по code-глифам.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:03:34 +03:00
agent_coder d4581a096f feat(mcp): детерминированная нормализация текста сносок + склейка форков по нормализованному тексту
Новый чистый модуль footnote-normalize-merge.ts (normalizeAndMergeFootnotes):
нормализует текст определений сносок (типографские кавычки->ASCII, тире->'-',
NBSP/спецпробелы->пробел, схлопывание пробелов, trim) и сливает определения с
совпавшим нормализованным текстом, перевешивая ссылки на канонический id;
дубли-сироты добивает canonicalizeFootnotes. Ключ слияния attrs-aware
(footnoteMergeKey/stableAttrs) — сноски с одинаковым текстом, но разными attrs
марок (напр. link.href) НЕ сливаются (защита от потери target). Пасс вызывается
строго ПЕРЕД canonicalizeFootnotes на 5 write-путях MCP (markdown-импорт,
updatePageJson, copyPageContent, docmost_transform, insertInlineFootnote).
Глиф-карты продублированы из comment-anchor.ts (там private+завязаны на golden).
Идемпотентен, чистый (deep-clone), scope строго внутри footnoteDefinition.

closes #419

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 07:03:34 +03:00
9 changed files with 1290 additions and 12 deletions
+197 -2
View File
@@ -82,6 +82,7 @@ import {
canonicalizeFootnotes,
insertInlineFootnote,
} from "./lib/transforms.js";
import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js";
import vm from "node:vm";
// Supported image types, kept as two lookup tables so both a local file
@@ -196,6 +197,166 @@ function readCollabTokenTtlMs(): number {
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
}
// --- Issue #437: central error diagnostics -------------------------------
// The agent only ever sees the thrown exception's `error.message`, so a failed
// tool must return an ACTIONABLE message (method, path, status, and the
// server's own validation text) instead of the opaque "Request failed with
// status code 400". These helpers + the response interceptor in the
// constructor are the single authoritative place that text is composed.
// Overall cap on the composed diagnostic message so the model context stays
// compact and a (whitelisted) server string can never blow up the text.
const ERROR_MESSAGE_CAP = 300;
// Only attempt to JSON.parse an arraybuffer body under this size: a larger
// binary body is never a JSON error envelope, so parsing it just wastes memory
// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch
// carries the JSON error envelope as raw bytes here).
const ERROR_BUFFER_PARSE_CAP = 4096;
// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant-
// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the
// canonical shape/length is enforced, not the version/variant nibble.
const FULL_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Throw an actionable error BEFORE any network call when `value` is not a full
* canonical UUID. Absorbs #436: a truncated/short comment id used to reach the
* server and bounce back as an opaque 400/404 the agent could not self-correct;
* failing fast here names the exact fix.
*/
export function assertFullUuid(
tool: string,
param: string,
value: string,
): void {
if (typeof value !== "string" || !FULL_UUID_RE.test(value)) {
throw new Error(
`${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` +
`019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` +
`verbatim from list_comments / create_comment output.`,
);
}
}
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
// so the message never leaks a host or query params. Resolves a relative
// config.url against config.baseURL, then discards everything but the path.
function requestPath(config: any): string {
const rawUrl = typeof config?.url === "string" ? config.url : "";
const base =
typeof config?.baseURL === "string" ? config.baseURL : undefined;
try {
// A dummy base makes an absolute config.url parse too; its host is dropped.
return new URL(rawUrl, base ?? "http://localhost").pathname;
} catch {
// Malformed url: still strip any query/fragment manually.
return rawUrl.split(/[?#]/)[0] || rawUrl;
}
}
/**
* Compose the server-facing message from `error.response.data`, using ONLY the
* whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the
* raw response body, headers (Authorization!) and config are NEVER read here —
* a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in
* favour of the statusText.
*/
function extractServerMessage(data: any, statusText: string): string {
// class-validator envelope: { message: string | string[], error?: string }.
if (
data &&
typeof data === "object" &&
!Buffer.isBuffer(data) &&
!(data instanceof ArrayBuffer)
) {
const msg = (data as any).message;
if (Array.isArray(msg)) {
const joined = msg.filter((m) => typeof m === "string").join("; ");
if (joined) return joined;
} else if (typeof msg === "string" && msg) {
return msg;
}
const err = (data as any).error;
if (typeof err === "string" && err) return err;
return statusText;
}
// Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a
// failed arraybuffer fetch still surfaces the server's validation text.
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) {
try {
return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText);
} catch {
return statusText;
}
}
return statusText;
}
// A raw string / HTML body is never surfaced (may echo server internals).
return statusText;
}
/**
* Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic:
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
* or, when the request never got a response:
* `<METHOD> <path> failed: <code> (no response from server)`.
*
* Mutates the SAME error object (never a custom subclass) so the live
* axios.isAxiosError / error.response?.status / config._retry checks around the
* client keep working, and sets `_docmostFormatted` as a double-processing
* guard. A no-op on a non-axios or already-formatted error.
*/
export function formatDocmostAxiosError(error: any): void {
if (!error || error._docmostFormatted) return;
if (!axios.isAxiosError(error)) return;
const config: any = error.config ?? {};
const method =
typeof config.method === "string" ? config.method.toUpperCase() : "";
const methodPath = `${method} ${requestPath(config)}`.trim();
const response = error.response;
let message: string;
if (response) {
const statusText =
typeof response.statusText === "string" ? response.statusText : "";
const serverMessage = extractServerMessage(response.data, statusText);
message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`;
// Full body only to stderr under DEBUG (parity with downloadImage).
if (process.env.DEBUG) {
console.error(
"Docmost request failed; response body:",
JSON.stringify(response.data),
);
}
} else {
// No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout).
// Use ONLY error.code, never the raw error.message: axios network messages
// embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo
// ENOTFOUND host") and #437's invariant is that the host never reaches the
// model-visible message. code is set for essentially every real no-response
// error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full
// native message still goes to stderr under DEBUG.
const reason = error.code ?? "network error";
message = `${methodPath} failed: ${reason} (no response from server)`;
if (process.env.DEBUG) {
console.error("Docmost request failed; no response:", error.message);
}
}
if (message.length > ERROR_MESSAGE_CAP) {
message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…";
}
error.message = message;
(error as any)._docmostFormatted = true;
}
export class DocmostClient {
private client: AxiosInstance;
private token: string | null = null;
@@ -336,6 +497,22 @@ export class DocmostClient {
return Promise.reject(error);
},
);
// Diagnostics interceptor (issue #437). Registered AFTER the re-login
// interceptor so a successful re-login retry (which resolves to a real
// response) is never seen here as an error; only a genuine failure reaches
// this rejection handler. It reformats error.message IN PLACE (see
// formatDocmostAxiosError — kept as a mutation, not a custom Error class, so
// the surrounding axios.isAxiosError / error.response?.status / config._retry
// checks keep working) and re-rejects the SAME error. The _docmostFormatted
// flag makes a re-processed retry-failure a no-op.
this.client.interceptors.response.use(
(response) => response,
(error) => {
formatDocmostAxiosError(error);
return Promise.reject(error);
},
);
}
/** Application base URL (API URL without the /api suffix). */
@@ -1639,6 +1816,8 @@ export class DocmostClient {
// leave footnotes out of order, orphaned, or in multiple lists — the bottom
// list + numbering are always derived from reference order. No-op when the
// footnotes are already canonical.
// #419: normalize + merge glyph-forked definitions before canonicalizing.
doc = normalizeAndMergeFootnotes(doc);
doc = canonicalizeFootnotes(doc);
// Write the BODY first, then the title (#159 split-brain): a failed body
@@ -1903,7 +2082,8 @@ export class DocmostClient {
// footnotes before copying — a no-op on already-canonical source content, but
// it guarantees a copy can never propagate a non-canonical footnote topology
// to the target (parity with the other full-doc write paths).
const canonical = canonicalizeFootnotes(content);
// #419: normalize + merge glyph-forked definitions before canonicalizing.
const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content));
const collabToken = await this.getCollabTokenWithReauth();
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
@@ -2407,6 +2587,8 @@ export class DocmostClient {
}
async getComment(commentId: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("get_comment", "commentId", commentId);
await this.ensureAuthenticated();
const response = await this.client.post("/comments/info", { commentId });
const comment = response.data.data || response.data;
@@ -2496,6 +2678,12 @@ export class DocmostClient {
parentCommentId?: string,
suggestedText?: string,
) {
// Fail fast (#436): a provided parent id must be a full UUID before any
// network call. Validate only when truthy — a falsy parentCommentId means
// "top-level comment" (mirrors the isReply computation below), not a reply.
if (parentCommentId) {
assertFullUuid("create_comment", "parentCommentId", parentCommentId);
}
await this.ensureAuthenticated();
const isReply = !!parentCommentId;
@@ -2778,6 +2966,8 @@ export class DocmostClient {
}
async updateComment(commentId: string, content: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("update_comment", "commentId", commentId);
await this.ensureAuthenticated();
// NON-canonicalizing on purpose (comment body — see createComment).
const jsonContent = await markdownToProseMirror(content);
@@ -2793,6 +2983,8 @@ export class DocmostClient {
}
async deleteComment(commentId: string) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("delete_comment", "commentId", commentId);
await this.ensureAuthenticated();
return this.client
.post("/comments/delete", { commentId })
@@ -2805,6 +2997,8 @@ export class DocmostClient {
* rejects resolving a reply. Hits POST /comments/resolve.
*/
async resolveComment(commentId: string, resolved: boolean) {
// Fail fast (#436): reject a truncated id before any network call.
assertFullUuid("resolve_comment", "commentId", commentId);
await this.ensureAuthenticated();
const response = await this.client.post("/comments/resolve", {
commentId,
@@ -4158,7 +4352,8 @@ export class DocmostClient {
// path can leave footnotes out of order / orphaned / in a raw `[^id]`
// block. In a dryRun preview this may surface footnote edits the script
// author did not write (the canonicalizer tidied them) — that is expected.
const result = canonicalizeFootnotes(raw);
// #419: normalize + merge glyph-forked definitions before canonicalizing.
const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw));
newDoc = result;
return result;
};
+21 -4
View File
@@ -202,6 +202,21 @@ export class CollabSession {
this.ydoc = new Y.Doc();
}
/**
* Shared diagnostic suffix (issue #437) appended to the connect-timeout,
* persist-timeout and connection-closed error texts: names the offending
* pageId and tells the agent this class of failure is transient (retry once)
* vs. a persistent collab-server outage, so it can self-correct instead of
* blind-looping. The Yjs-encode error is deliberately NOT touched — it
* already names the offending attribute.
*/
private hint(): string {
return (
`(pageId ${this.pageId}; transient — retry once; persistent failures ` +
`mean the collab server is unreachable/overloaded)`
);
}
/**
* A cached session may be reused only when it is fully ready, still synced,
* has not lost its connection, and has not exceeded its max age (invariant 5
@@ -232,7 +247,9 @@ export class CollabSession {
// The 25s connect timeout: the collab connection never became ready.
this.opts?.onConnectTimeout?.();
this.teardown(
new Error("Connection timeout to collaboration server"),
new Error(
`Connection timeout to collaboration server ${this.hint()}`,
),
false,
);
}, CONNECT_TIMEOUT_MS);
@@ -259,7 +276,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Disconnect");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -268,7 +285,7 @@ export class CollabSession {
if (process.env.DEBUG) console.error("WS Close");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
`Collaboration connection closed before the update was persisted/synced ${this.hint()}`,
),
true,
);
@@ -403,7 +420,7 @@ export class CollabSession {
persistTimer = setTimeout(() => {
localFinish(
new Error(
"Timeout waiting for collaboration server to persist the update",
`Timeout waiting for collaboration server to persist the update ${this.hint()}`,
),
);
}, PERSIST_TIMEOUT_MS);
+7 -1
View File
@@ -15,6 +15,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import { VerifyReport } from "./diff.js";
import { acquireCollabSession } from "./collab-session.js";
@@ -82,7 +83,12 @@ global.WebSocket = WebSocket;
export async function markdownToProseMirrorCanonical(
markdownContent: string,
): Promise<any> {
return canonicalizeFootnotes(await markdownToProseMirror(markdownContent));
// #419: normalize + merge glyph-forked footnote definitions BEFORE
// canonicalizing, so the canonicalizer re-hangs references and drops the
// now-orphaned duplicate definitions.
return canonicalizeFootnotes(
normalizeAndMergeFootnotes(await markdownToProseMirror(markdownContent)),
);
}
/**
@@ -0,0 +1,280 @@
/**
* Deterministic server-side NORMALIZATION + MERGE of footnote DEFINITIONS
* (MCP, PURE).
*
* Problem (#419): footnotes with the same meaning but different GLYPHS —
* typographic quotes («…»/“…”) vs ASCII "…", em/en-dash vs `-`, non-breaking
* space vs normal space, differing space counts — are not recognized as equal
* and "fork": two definitions appear where the author meant one. The existing
* de-dup paths miss this: `footnoteContentKey` (footnote-authoring.ts) only
* collapses ASCII whitespace (quotes/dashes/NBSP untouched), and
* `canonicalizeFootnotes` keys purely by `attrs.id` (the two forks have
* different ids), so neither glues the forks together.
*
* This pass fixes that DETERMINISTICALLY on the MCP write-paths (an LLM
* instruction gives no glue guarantee). It:
* 1. Normalizes the TEXT of every `footnoteDefinition`'s text nodes IN PLACE
* (typographic quotes -> ASCII "/', dashes -> `-`, NBSP & friends ->
* normal space, whitespace runs collapsed, whole-definition edges
* trimmed) — unconditionally, for ALL definitions, KEEPING their marks.
* 2. Computes a MERGE KEY per definition (normalized text + an ATTRS-AWARE
* inline-mark signature, via the local `footnoteMergeKey`), so notes that
* read the same but differ in formatting (bold vs plain) OR in a mark
* attribute (a `link` with a different `href`, differing `code`/`highlight`
* attrs) are NOT merged. See `footnoteMergeKey` for why this diverges from
* the shared type-only `footnoteContentKey`.
* 3. Maps every duplicate definition id to the FIRST (document-order)
* definition's id and re-hangs `footnoteReference` nodes onto it.
*
* Duplicate definitions keep their original ids but now have NO references, so
* the canonicalizer that runs immediately after this pass removes them as
* orphans and derives the single tail list + numbering. This pass therefore
* MUST run BEFORE `canonicalizeFootnotes(doc)` at every write-path call-site
* (see the enforcement rule in `footnote-canonicalize.ts`).
*
* Accepted tradeoff: the exact typographic glyphs of the SURVIVING footnote are
* rewritten to ASCII, in exchange for a GUARANTEED merge. Scope is strictly
* INSIDE `footnoteDefinition` — body text (normal paragraphs) is never touched.
*
* Pure: deep-clones its input, deterministic, idempotent (a re-run is a no-op —
* text is already normalized and references already point at the canonical id,
* so no spurious mutations / git-sync churn).
*/
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
/**
* Typographic glyph maps. DUPLICATED from `comment-anchor.ts` (the source of
* truth, `normalizeForMatch`) on purpose: those constants are private there and
* bound to that module's anchor-matching golden tests, so extracting them would
* risk changing anchor behaviour. Keeping a local copy makes this pass fully
* self-contained. If the anchor maps grow, mirror the change here.
*/
/** Typographic double-quote variants mapped to ASCII `"`. */
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
const SINGLE_QUOTES = "‘’‚‛";
/** Dash variants mapped to ASCII `-`. */
const DASHES = "–—―−‐‑‒";
function cloneJson<T>(v: T): T {
if (typeof structuredClone === "function") return structuredClone(v);
return JSON.parse(JSON.stringify(v)) as T;
}
/**
* True for any character we collapse/replace with a single normal space.
* Mirrors `comment-anchor.ts`'s `isWhitespaceChar`: ASCII whitespace (`\s`
* covers tab/newline) plus the non-breaking / special spaces listed explicitly
* for determinism across engines.
*/
function isWhitespaceChar(ch: string): boolean {
return (
/\s/.test(ch) ||
ch === " " || // no-break space
ch === " " || // figure space
ch === " " || // narrow no-break space
ch === " " || // thin space
ch === " " || // hair space
ch === " " || // en space
ch === " " // em space
);
}
/**
* Map typographic quotes/dashes to ASCII and collapse every whitespace run
* (including NBSP & friends) to a SINGLE normal space. Does NOT trim — the
* whole-definition edge trim is applied separately so inter-node spacing across
* a multi-text-node definition is preserved.
*/
function normalizeAndCollapse(s: string): string {
let out = "";
let i = 0;
while (i < s.length) {
const ch = s[i];
if (isWhitespaceChar(ch)) {
while (i < s.length && isWhitespaceChar(s[i])) i++;
out += " ";
continue;
}
let mapped = ch;
if (DOUBLE_QUOTES.indexOf(ch) !== -1) mapped = '"';
else if (SINGLE_QUOTES.indexOf(ch) !== -1) mapped = "'";
else if (DASHES.indexOf(ch) !== -1) mapped = "-";
out += mapped;
i++;
}
return out;
}
/** Collect every text node inside `def`, in document order (deep). */
function collectTextNodes(node: any, out: any[]): void {
if (!node || typeof node !== "object") return;
if (node.type === "text" && typeof node.text === "string") out.push(node);
if (Array.isArray(node.content)) {
for (const child of node.content) collectTextNodes(child, out);
}
}
/** Collect every `footnoteDefinition` node in document order (deep). */
function collectDefinitions(node: any, out: any[]): void {
if (!node || typeof node !== "object") return;
if (node.type === FOOTNOTE_DEFINITION_NAME) out.push(node);
if (Array.isArray(node.content)) {
for (const child of node.content) collectDefinitions(child, out);
}
}
/**
* Normalize the text of one definition's text nodes IN PLACE: map glyphs +
* collapse whitespace on every node (marks untouched), then trim the leading
* edge of the first text node and the trailing edge of the last so the
* definition as a whole is trimmed WITHOUT dropping the spacing between two
* adjacent text nodes. The edge trims are guarded so an all-whitespace edge
* node is never emptied into a schema-invalid empty text node.
*/
function normalizeDefinitionText(def: any): void {
const textNodes: any[] = [];
collectTextNodes(def, textNodes);
for (const t of textNodes) {
// Skip text carrying a `code` mark: inline code is a verbatim literal, not
// prose typography. Rewriting quotes/dashes/special-spaces there would
// corrupt the literal's meaning (a string literal, an em-dash flag, i18n).
// Leaving it untouched also makes it contribute its RAW text to
// `footnoteMergeKey`, so two notes differing only by glyphs inside code
// stay distinct (while prose glyph-forks still merge). See #419.
if ((t.marks || []).some((m: any) => m?.type === "code")) continue;
t.text = normalizeAndCollapse(t.text);
}
if (textNodes.length === 0) return;
const hasCodeMark = (t: any): boolean =>
(t.marks || []).some((m: any) => m?.type === "code");
const first = textNodes[0];
if (!hasCodeMark(first)) {
const startTrimmed = first.text.replace(/^ +/, "");
if (startTrimmed !== "") first.text = startTrimmed;
}
const last = textNodes[textNodes.length - 1];
if (!hasCodeMark(last)) {
const endTrimmed = last.text.replace(/ +$/, "");
if (endTrimmed !== "") last.text = endTrimmed;
}
}
/** Rewrite `footnoteReference` ids IN PLACE using `defIdToCanon` (deep). */
function rehangReferences(
node: any,
defIdToCanon: Map<string, string>,
): void {
if (!node || typeof node !== "object") return;
if (node.type === FOOTNOTE_REFERENCE_NAME) {
const id = node?.attrs?.id;
if (typeof id === "string") {
const canon = defIdToCanon.get(id);
if (canon && canon !== id) node.attrs.id = canon;
}
}
if (Array.isArray(node.content)) {
for (const child of node.content) rehangReferences(child, defIdToCanon);
}
}
/**
* Stable, order-independent serialization of a mark's `attrs`: sort keys so the
* same attrs always yield the same string regardless of authoring order. Empty /
* missing attrs -> "" (so an attr-less mark keys identically to a type-only mark
* signature, preserving bold-vs-plain parity).
*/
function stableAttrs(attrs: any): string {
if (!attrs || typeof attrs !== "object") return "";
const sorted: Record<string, any> = {};
for (const k of Object.keys(attrs).sort()) sorted[k] = attrs[k];
return JSON.stringify(sorted);
}
/**
* ATTRS-AWARE merge key for a footnote definition. Deliberately DIVERGES from
* the shared `footnoteContentKey` (footnote-authoring.ts): that key's mark
* signature is TYPE-ONLY (`m.type`), so two definitions with identical visible
* text but marks differing only in ATTRIBUTES — most importantly a `link` with a
* different `href` (footnotes are usually citations/links), also `code` /
* `highlight` with differing attrs — collapse to the SAME key and get merged;
* one definition then loses its references and the canonicalizer deletes it as an
* orphan, silently dropping a distinct link target (data loss, #419).
*
* This key folds each mark's `attrs` (stable, sorted-key serialization) into the
* signature, so different-href / different-attr notes stay separate. We do NOT
* change `footnoteContentKey` itself: it is shared with the live
* `insertInlineFootnote` / `commentsToFootnotes` dedup and altering it there
* would change their behaviour — out of scope here.
*
* The TEXT portion mirrors `footnoteContentKey` exactly (per text node
* `text + mark-signature`, concatenated, whitespace-collapsed, trimmed) over the
* already-in-place-normalized text, so empty text still yields "" (empties never
* collapse) and merge parity with the rest of the pass is preserved.
*/
function footnoteMergeKey(defNode: any): string {
const parts: string[] = [];
const visit = (n: any): void => {
if (!n || typeof n !== "object") return;
if (n.type === "text" && typeof n.text === "string") {
const marks = Array.isArray(n.marks)
? n.marks
.filter((m: any) => m && m.type)
.map((m: any) => `${m.type}${stableAttrs(m.attrs)}`)
.sort()
.join(",")
: "";
parts.push(`${n.text}${marks}`);
}
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
};
visit(defNode);
return parts
.join("")
.replace(/[ \t\r\n]+/g, " ")
.trim();
}
/**
* Normalize footnote-definition text and merge definitions whose normalized
* text (+ mark signature) matches. See the file header for the full contract.
* Pure (deep-clones input, deterministic, idempotent). Intended to run
* immediately BEFORE `canonicalizeFootnotes(doc)`.
*/
export function normalizeAndMergeFootnotes<T = any>(doc: T): T {
if (doc == null || typeof doc !== "object") return doc;
const out = cloneJson(doc) as any;
// 1) All definitions in document order; normalize each one's text in place.
const defNodes: any[] = [];
collectDefinitions(out, defNodes);
for (const def of defNodes) normalizeDefinitionText(def);
// 2) Merge key per definition (normalized text + inline-mark signature). The
// first definition in document order per key wins; later ones map onto it.
// Empty-text definitions (key === "") are NOT merged — otherwise every
// empty footnote would collapse into one (parity with insertInlineFootnote).
const keyToCanon = new Map<string, string>();
const defIdToCanon = new Map<string, string>();
for (const def of defNodes) {
const id = def?.attrs?.id;
if (typeof id !== "string" || id === "") continue;
const key = footnoteMergeKey(def);
if (key === "") continue;
const canon = keyToCanon.get(key);
if (canon === undefined) {
keyToCanon.set(key, id);
} else if (canon !== id) {
defIdToCanon.set(id, canon);
}
}
// 3) Re-hang references from duplicate ids onto the canonical id. Duplicate
// definitions keep their ids but now have no references -> the following
// canonicalizer pass drops them as orphans.
if (defIdToCanon.size > 0) rehangReferences(out, defIdToCanon);
return out;
}
+3
View File
@@ -14,6 +14,7 @@
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
*/
import { normalizeAndMergeFootnotes } from "./footnote-normalize-merge.js";
import {
blockPlainText,
footnoteContentKey,
@@ -766,6 +767,8 @@ export function insertInlineFootnote(
appendDefinition(working, makeFootnoteDefinition(footnoteId, inline));
}
// #419: normalize + merge glyph-forked definitions before canonicalizing.
working = normalizeAndMergeFootnotes(working);
// Derive numbering + the single bottom list deterministically.
working = canonicalizeFootnotes(working);
return { doc: working, inserted: true, footnoteId, reused };
@@ -203,14 +203,16 @@ test("a reply creates without selection or anchoring and is stored as type 'page
"reply body",
"inline",
undefined,
"parent-123",
// #437: a parentCommentId must be a full canonical UUID.
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
);
assert.equal(result.success, true, "a reply must resolve successfully");
assert.ok(createPayload, "/comments/create must have been called");
assert.equal(
createPayload.parentCommentId,
"parent-123",
// #437: a parentCommentId must be a full canonical UUID.
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
"the reply payload must carry the parentCommentId",
);
assert.equal(
@@ -321,7 +323,9 @@ test("suggestedText on a reply is rejected", async () => {
"body",
"inline",
undefined,
"parent-1",
// #437: use a valid full UUID so the reply+suggestion rejection fires
// (not the id-shape guard).
"019f499a-9f8c-7d68-b7be-ce100d7c6c56",
"replacement",
),
/reply/i,
@@ -168,7 +168,9 @@ test("an in-flight mutate rejects with the connection-closed text on disconnect"
FakeProvider.last()._disconnect();
await assert.rejects(
p,
/Collaboration connection closed before the update was persisted\/synced/,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue),
// so a refactor that drops hint() can't pass this vacuously.
/Collaboration connection closed before the update was persisted\/synced \(pageId page-1; transient/,
);
});
@@ -248,7 +250,11 @@ test("connect timeout rejects with the connect-timeout text and fires the metric
},
});
mock.timers.tick(25000);
await assert.rejects(p, /Connection timeout to collaboration server/);
await assert.rejects(
p,
// Assert the #437 diagnostic hint tail too (pageId + transient/retry cue).
/Connection timeout to collaboration server \(pageId page-1; transient/,
);
assert.equal(metricFired, 1);
assert.equal(__sessionCountForTests(), 0);
});
@@ -0,0 +1,450 @@
// Issue #437: central error diagnostics.
//
// Two surfaces are covered here:
// 1. formatDocmostAxiosError — the pure response-interceptor body that
// rewrites an AxiosError's `.message` into an actionable diagnostic.
// 2. assertFullUuid — the fail-fast comment-id guard (absorbs #436) that must
// throw BEFORE any network call.
// Plus an end-to-end pass over a real (offline) http server to prove the
// interceptor is wired, that a re-login retry leaves a success untouched, and
// that a persistent failure gets formatted — and that an invalid comment id
// short-circuits every comment tool with ZERO network traffic.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import axios, { AxiosError } from "axios";
import {
DocmostClient,
formatDocmostAxiosError,
assertFullUuid,
} from "../../build/client.js";
// Build an AxiosError-shaped object the way the interceptor's rejection handler
// receives it. Using the real AxiosError ctor makes axios.isAxiosError() true.
function makeAxiosError({
method = "post",
url = "/comments/resolve",
baseURL = "http://host.example/api",
status,
statusText,
data,
code,
message = "Request failed",
}) {
const config = { method, url, baseURL };
const response =
status === undefined
? undefined
: { status, statusText, data, headers: {}, config };
return new AxiosError(message, code, config, {}, response);
}
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: message-body extraction rules.
// ---------------------------------------------------------------------------
test("class-validator message array is joined with '; '", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: ["commentId must be a UUID", "resolved must be a boolean"] },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID; resolved must be a boolean",
);
});
test("a string message is used as-is", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve",
status: 400,
statusText: "Bad Request",
data: { message: "commentId must be a UUID" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): commentId must be a UUID",
);
});
test("falls back to data.error when message is absent", () => {
const err = makeAxiosError({
method: "get",
url: "/pages/info",
status: 403,
statusText: "Forbidden",
data: { error: "Forbidden" },
});
formatDocmostAxiosError(err);
assert.equal(err.message, "GET /pages/info failed (403 Forbidden): Forbidden");
});
test("empty object body falls back to statusText", () => {
const err = makeAxiosError({
status: 404,
statusText: "Not Found",
url: "/comments/info",
data: {},
});
formatDocmostAxiosError(err);
assert.equal(err.message, "POST /comments/info failed (404 Not Found): Not Found");
});
test("HTML/string body is NEVER surfaced — only the statusText", () => {
const html = "<html><body>502 Bad Gateway — nginx internals here</body></html>";
const err = makeAxiosError({
status: 502,
statusText: "Bad Gateway",
url: "/comments/create",
data: html,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed (502 Bad Gateway): Bad Gateway",
);
assert.ok(!err.message.includes("nginx"), "raw HTML body must not leak");
assert.ok(!err.message.includes("<html>"), "raw HTML body must not leak");
});
test("Buffer body carrying JSON is parsed for its message", () => {
const buf = Buffer.from(JSON.stringify({ message: "file too large" }), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 413,
statusText: "Payload Too Large",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (413 Payload Too Large): file too large",
);
});
test("Buffer body with non-JSON garbage falls back to statusText", () => {
const buf = Buffer.from("<<< not json at all >>>", "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
assert.ok(!err.message.includes("not json"), "raw buffer body must not leak");
});
test("an oversized Buffer body is not parsed (size cap) — statusText only", () => {
// A >4KB JSON buffer: even though it IS valid JSON with a message, the size
// cap means we do not attempt to parse it, so only the statusText survives.
const big = { message: "x".repeat(5000) };
const buf = Buffer.from(JSON.stringify(big), "utf8");
const err = makeAxiosError({
method: "get",
url: "/files/abc/x.png",
status: 500,
statusText: "Internal Server Error",
data: buf,
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"GET /files/abc/x.png failed (500 Internal Server Error): Internal Server Error",
);
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: no-response and path/method handling.
// ---------------------------------------------------------------------------
test("no response uses error.code + path + 'no response from server'", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
code: "ECONNREFUSED",
message: "connect ECONNREFUSED 127.0.0.1:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: ECONNREFUSED (no response from server)",
);
});
test("no response with no code falls back to a neutral reason (raw message not leaked — it may embed host:port)", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/create",
status: undefined,
// A raw axios network message like "connect ECONNREFUSED 127.0.0.1:3000"
// embeds the host; #437's invariant is that it never reaches the message.
message: "connect ECONNREFUSED 10.0.0.5:3000",
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/create failed: network error (no response from server)",
);
// And the host must NOT appear anywhere in the model-visible message.
assert.ok(!err.message.includes("10.0.0.5"));
});
test("path drops the host and the query string", () => {
const err = makeAxiosError({
method: "post",
url: "/comments/resolve?token=secret&x=1",
baseURL: "https://docs.example.com/api",
status: 400,
statusText: "Bad Request",
data: { message: "bad" },
});
formatDocmostAxiosError(err);
assert.equal(
err.message,
"POST /comments/resolve failed (400 Bad Request): bad",
);
assert.ok(!err.message.includes("secret"), "query string must not leak");
assert.ok(!err.message.includes("docs.example.com"), "host must not leak");
});
// ---------------------------------------------------------------------------
// formatDocmostAxiosError: length cap + guard flag + pass-through.
// ---------------------------------------------------------------------------
test("the overall message is capped at ~300 chars", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "y".repeat(1000) },
});
formatDocmostAxiosError(err);
assert.ok(err.message.length <= 300, `expected <=300, got ${err.message.length}`);
assert.ok(err.message.endsWith("…"), "a truncated message ends with an ellipsis");
});
test("a formatted error is not re-processed (guard flag)", () => {
const err = makeAxiosError({
status: 400,
statusText: "Bad Request",
data: { message: "first" },
});
formatDocmostAxiosError(err);
const once = err.message;
assert.equal(err._docmostFormatted, true);
// Mutate the body and re-run: the guard makes it a no-op.
err.response.data = { message: "second" };
formatDocmostAxiosError(err);
assert.equal(err.message, once, "the guard flag prevents double-processing");
});
test("a non-axios error is passed through untouched", () => {
const plain = new Error("boom");
formatDocmostAxiosError(plain);
assert.equal(plain.message, "boom");
assert.equal(plain._docmostFormatted, undefined);
});
// ---------------------------------------------------------------------------
// assertFullUuid.
// ---------------------------------------------------------------------------
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
assert.doesNotThrow(() => assertFullUuid("resolve_comment", "commentId", GOOD_UUID));
// A v4 id also passes (version/variant-agnostic).
assert.doesNotThrow(() =>
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
);
});
test("assertFullUuid rejects a truncated prefix", () => {
assert.throws(
() => assertFullUuid("resolve_comment", "commentId", "019f499a"),
(e) =>
e.message.startsWith(
"resolve_comment: 'commentId' must be the FULL comment UUID",
) &&
e.message.includes("got '019f499a'") &&
e.message.includes("Copy the id verbatim"),
);
});
test("assertFullUuid rejects garbage and empty string", () => {
assert.throws(
() => assertFullUuid("delete_comment", "commentId", "not-a-uuid"),
/must be the FULL comment UUID.*got 'not-a-uuid'/s,
);
assert.throws(
() => assertFullUuid("update_comment", "commentId", ""),
/must be the FULL comment UUID.*got ''/s,
);
});
// ---------------------------------------------------------------------------
// End-to-end over an offline http server: interceptor wiring + re-login.
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
});
test("a 400 on a JSON endpoint is reformatted by the wired interceptor", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 400, { message: "pageId should not be empty" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
await assert.rejects(
() => client.getPageRaw("x"),
(e) => {
assert.ok(axios.isAxiosError(e), "still an AxiosError (mutation, not a subclass)");
assert.equal(e.response?.status, 400, "error.response?.status still readable");
assert.equal(
e.message,
"POST /pages/info failed (400 Bad Request): pageId should not be empty",
);
return true;
},
);
});
test("401 -> re-login -> successful retry: the SUCCESS message is untouched", async () => {
let infoCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
if (infoCalls === 1) sendJson(res, 401, { message: "Unauthorized" });
else sendJson(res, 200, { success: true, data: { id: "ws" } });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
const result = await client.getWorkspace();
assert.equal(result.success, true, "the retried request resolved successfully");
assert.equal(infoCalls, 2, "401 then a successful replay");
});
test("401 -> re-login -> persistent failure: formatted AND retry guard intact", async () => {
let infoCalls = 0;
let loginCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
loginCalls++;
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=fresh; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/workspace/info") {
infoCalls++;
// Always 401, even after a fresh login: the _retry guard must stop here.
sendJson(res, 401, { message: "token still invalid" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
client.token = "stale";
client.client.defaults.headers.common["Authorization"] = "Bearer stale";
await assert.rejects(
() => client.getWorkspace(),
(e) => {
assert.equal(
e.message,
"POST /workspace/info failed (401 Unauthorized): token still invalid",
);
return true;
},
);
// The _retry guard is intact: exactly one replay (2 hits), one re-login.
assert.equal(infoCalls, 2, "endpoint hit at most twice (one retry only)");
assert.equal(loginCalls, 1, "re-login attempted exactly once");
});
// ---------------------------------------------------------------------------
// assertFullUuid application points: NO network call when the id is invalid.
// A server that counts EVERY request proves the guard short-circuits before
// even the login round-trip.
// ---------------------------------------------------------------------------
test("all 5 comment-id call sites reject a bad id with ZERO network traffic", async () => {
let requests = 0;
const { baseURL } = await spawn(async (req, res) => {
requests++;
await readBody(req);
sendJson(res, 200, { success: true });
});
const client = new DocmostClient(baseURL, "u@example.com", "pw");
const bad = "019f499a"; // truncated
await assert.rejects(() => client.resolveComment(bad, true), /resolve_comment: 'commentId'/);
await assert.rejects(() => client.updateComment(bad, "hi"), /update_comment: 'commentId'/);
await assert.rejects(() => client.deleteComment(bad), /delete_comment: 'commentId'/);
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
// createComment validates parentCommentId only when provided.
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "sel", bad),
/create_comment: 'parentCommentId'/,
);
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
});
@@ -0,0 +1,317 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { normalizeAndMergeFootnotes } from "../../build/lib/footnote-normalize-merge.js";
import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js";
function findAll(node, type, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === type) acc.push(node);
if (Array.isArray(node.content)) {
for (const c of node.content) findAll(c, type, acc);
}
return acc;
}
const defs = (doc) => findAll(doc, "footnoteDefinition");
const defIds = (doc) => defs(doc).map((d) => d.attrs.id);
const refIds = (doc) => findAll(doc, "footnoteReference").map((r) => r.attrs.id);
const defText = (d) =>
findAll(d, "text")
.map((t) => t.text)
.join("");
const ref = (id) => ({ type: "footnoteReference", attrs: { id } });
const para = (...inline) => ({ type: "paragraph", content: inline });
const txt = (text, marks) =>
marks ? { type: "text", text, marks } : { type: "text", text };
const def = (id, ...inline) => ({
type: "footnoteDefinition",
attrs: { id },
content: [para(...inline)],
});
const list = (...defs) => ({ type: "footnotesList", content: defs });
const doc = (...content) => ({ type: "doc", content });
// --- Normalization + merge of glyph forks ----------------------------------
test("typographic double quotes «…» vs \"…\" merge into one", () => {
const d = doc(
para(txt("a"), ref("A"), txt(" b"), ref("B")),
list(def("A", txt("«word»")), def("B", txt('"word"'))),
);
const out = normalizeAndMergeFootnotes(d);
// Both references now point at the first definition's id.
assert.deepEqual(refIds(out), ["A", "A"]);
// Surviving text is ASCII-normalized.
assert.equal(defText(defs(out)[0]), '"word"');
// Duplicate def kept its id (canonicalizer removes it as an orphan later).
const canon = canonicalizeFootnotes(out);
assert.deepEqual(defIds(canon), ["A"]);
assert.equal(findAll(canon, "footnotesList").length, 1);
});
test("em/en dash and hyphen merge", () => {
const d = doc(
para(ref("A"), ref("B"), ref("C")),
list(
def("A", txt("see — here")),
def("B", txt("see – here")),
def("C", txt("see - here")),
),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A", "A"]);
assert.equal(defText(defs(out)[0]), "see - here");
});
test("NBSP and extra spaces merge with normal spacing", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("foo bar")), // NBSP
def("B", txt("foo bar")), // collapsed spaces
),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
assert.equal(defText(defs(out)[0]), "foo bar");
});
test("same text but different styling (bold vs plain) does NOT merge", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("word", [{ type: "bold" }])),
def("B", txt("word")),
),
);
const out = normalizeAndMergeFootnotes(d);
// No re-hang: references keep their own ids.
assert.deepEqual(refIds(out), ["A", "B"]);
assert.deepEqual(defIds(out), ["A", "B"]);
// Marks preserved on the surviving text node.
assert.deepEqual(defs(out)[0].content[0].content[0].marks, [
{ type: "bold" },
]);
});
test("same text but a link mark with different href does NOT merge (data-loss guard)", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
def("B", txt("source", [{ type: "link", attrs: { href: "https://b.example/2" } }])),
),
);
const out = normalizeAndMergeFootnotes(d);
// No re-hang: each reference keeps its own definition (distinct link target).
assert.deepEqual(refIds(out), ["A", "B"]);
assert.deepEqual(defIds(out), ["A", "B"]);
// Both distinct hrefs survive.
assert.deepEqual(
defs(out).map((dn) => dn.content[0].content[0].marks[0].attrs.href),
["https://a.example/1", "https://b.example/2"],
);
// Canonicalize keeps both as two tail entries (neither is an orphan).
const canon = canonicalizeFootnotes(out);
assert.deepEqual(defIds(canon), ["A", "B"]);
assert.deepEqual(refIds(canon), ["A", "B"]);
});
test("same text and SAME link href still merges (attrs-aware key doesn't over-separate)", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
def("B", txt("source", [{ type: "link", attrs: { href: "https://a.example/1" } }])),
),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
const canon = canonicalizeFootnotes(out);
assert.deepEqual(defIds(canon), ["A"]);
assert.equal(findAll(canon, "footnotesList").length, 1);
});
test("marks are kept on merged (surviving) definition text", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("«x»", [{ type: "italic" }])),
def("B", txt("«x»", [{ type: "italic" }])),
),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
assert.deepEqual(defs(out)[0].content[0].content[0].marks, [
{ type: "italic" },
]);
assert.equal(defText(defs(out)[0]), '"x"');
});
// --- Inline code is verbatim (not typography) ------------------------------
test("text inside a code mark is left verbatim; prose in the same def is normalized", () => {
const d = doc(
para(ref("A")),
list({
type: "footnoteDefinition",
attrs: { id: "A" },
content: [
para(
txt("a—b «x»", [{ type: "code" }]),
txt(" prose «y» — z"),
),
],
}),
);
const out = normalizeAndMergeFootnotes(d);
const nodes = findAll(defs(out)[0], "text");
// Code node: byte-for-byte unchanged (typography preserved).
assert.equal(nodes[0].text, "a—b «x»");
// Prose node: dashes/quotes normalized to ASCII.
assert.equal(nodes[1].text, ' prose "y" - z');
});
test("two notes differing ONLY by glyphs inside a code mark do NOT merge", () => {
const d = doc(
para(ref("A"), ref("B")),
list(
def("A", txt("«x»", [{ type: "code" }]), txt(" same prose «q»")),
def("B", txt('"x"', [{ type: "code" }]), txt(" same prose «q»")),
),
);
const out = normalizeAndMergeFootnotes(d);
// Prose is identical after normalization, but the code literals differ raw
// -> the merge key diverges -> both definitions survive, no re-hang.
assert.deepEqual(refIds(out), ["A", "B"]);
assert.deepEqual(defIds(out), ["A", "B"]);
// Each code literal stays verbatim.
assert.equal(defs(out)[0].content[0].content[0].text, "«x»");
assert.equal(defs(out)[1].content[0].content[0].text, '"x"');
// Both survive canonicalization (neither is an orphan).
const canon = canonicalizeFootnotes(out);
assert.deepEqual(defIds(canon), ["A", "B"]);
assert.deepEqual(refIds(canon), ["A", "B"]);
});
// --- Composition with the canonicalizer ------------------------------------
test("pass + canonicalize: single tail list and sequential numbering", () => {
const d = doc(
para(txt("intro "), ref("A"), txt(" middle "), ref("B")),
list(def("A", txt("«note»")), def("B", txt('"note"'))),
);
const canon = canonicalizeFootnotes(normalizeAndMergeFootnotes(d));
assert.equal(findAll(canon, "footnotesList").length, 1);
assert.deepEqual(defIds(canon), ["A"]);
assert.deepEqual(refIds(canon), ["A", "A"]);
});
// --- Idempotency -----------------------------------------------------------
test("idempotent: a second run is a no-op", () => {
const d = doc(
para(ref("A"), ref("B")),
list(def("A", txt("«word»")), def("B", txt('"word"'))),
);
const once = normalizeAndMergeFootnotes(d);
const twice = normalizeAndMergeFootnotes(once);
assert.deepEqual(twice, once);
});
test("input document is not mutated (pure)", () => {
const d = doc(
para(ref("A"), ref("B")),
list(def("A", txt("«word»")), def("B", txt('"word"'))),
);
const snapshot = JSON.parse(JSON.stringify(d));
normalizeAndMergeFootnotes(d);
assert.deepEqual(d, snapshot);
});
// --- Nested definitions ----------------------------------------------------
test("definitions nested in a callout are normalized and merged", () => {
const callout = (...content) => ({
type: "callout",
attrs: { type: "info" },
content,
});
const d = doc(
para(ref("A"), ref("B")),
callout(list(def("A", txt("«c»")), def("B", txt('"c"')))),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
assert.equal(defText(defs(out)[0]), '"c"');
});
// --- Empty footnotes -------------------------------------------------------
test("empty footnotes do NOT collapse into each other", () => {
const d = doc(
para(ref("A"), ref("B")),
list(def("A", txt("")), { type: "footnoteDefinition", attrs: { id: "B" }, content: [{ type: "paragraph" }] }),
);
const out = normalizeAndMergeFootnotes(d);
// Both empty definitions keep distinct ids; references unchanged.
assert.deepEqual(refIds(out), ["A", "B"]);
assert.deepEqual(defIds(out), ["A", "B"]);
});
// --- Body text left untouched ----------------------------------------------
test("body text (outside footnotes) is NOT normalized", () => {
const d = doc(
para(txt("body «quoted» — dash"), ref("A")),
list(def("A", txt("«note»"))),
);
const out = normalizeAndMergeFootnotes(d);
// Body paragraph keeps its typographic glyphs verbatim.
assert.equal(out.content[0].content[0].text, "body «quoted» — dash");
// Footnote text IS normalized.
assert.equal(defText(defs(out)[0]), '"note"');
});
// --- Multi-paragraph structure preserved -----------------------------------
test("multi-paragraph definition: text normalized, structure preserved", () => {
const d = doc(
para(ref("A")),
list({
type: "footnoteDefinition",
attrs: { id: "A" },
content: [para(txt("«p1»")), para(txt("p2 — end"))],
}),
);
const out = normalizeAndMergeFootnotes(d);
const def0 = defs(out)[0];
assert.equal(def0.content.length, 2);
assert.equal(def0.content[0].content[0].text, '"p1"');
assert.equal(def0.content[1].content[0].text, "p2 - end");
});
// --- Multi-reference footnote not broken -----------------------------------
test("one id shared by multiple references is preserved", () => {
const d = doc(
para(ref("A"), txt(" x "), ref("A")),
list(def("A", txt("note"))),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
assert.deepEqual(defIds(out), ["A"]);
});
// --- Whole-definition edge trim --------------------------------------------
test("leading/trailing whitespace is trimmed for the merge and stored text", () => {
const d = doc(
para(ref("A"), ref("B")),
list(def("A", txt(" hello ")), def("B", txt("hello"))),
);
const out = normalizeAndMergeFootnotes(d);
assert.deepEqual(refIds(out), ["A", "A"]);
assert.equal(defText(defs(out)[0]), "hello");
});