From 047433595eb506229c11bde4b9bf7bc5d715b232 Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 18:14:39 +0300 Subject: [PATCH] =?UTF-8?q?refactor(mcp):=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF?= =?UTF-8?q?=20stripInlineMarkdown=20=E2=80=94=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=BE=D0=BD=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=BE=D0=BC=20=D0=BF=D0=B0=D0=BA=D0=B5=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Локаторная нормализация markdown (stripInlineMarkdown + примитив stripWrappersAndLinks с WRAPPER_PATTERNS/LINK_IMAGE_RE) была ФОРКНУТА один-в- один в packages/mcp/src/lib/text-normalize.ts и в каноническом @docmost/prosemirror-markdown (где ей пользуется node-ops). MCP теперь импортирует оба примитива из пакета (mcp и так от него зависит — цикла нет) и держит на них лишь свои тонкие надстройки stripBalancedWrappers/ closestBlockHint. ~60 строк дубля удалено, дрейф закрыт. Проверено: пакет node-ops (103) + весь MCP unit-suite (685) зелёные. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/lib/text-normalize.ts | 114 ++++-------------- .../prosemirror-markdown/src/lib/index.ts | 11 ++ .../src/lib/text-normalize.ts | 15 ++- 3 files changed, 39 insertions(+), 101 deletions(-) diff --git a/packages/mcp/src/lib/text-normalize.ts b/packages/mcp/src/lib/text-normalize.ts index e80bcfdb..f8480417 100644 --- a/packages/mcp/src/lib/text-normalize.ts +++ b/packages/mcp/src/lib/text-normalize.ts @@ -1,64 +1,30 @@ /** - * Locator normalization: strip inline markdown wrappers and trailing - * decoration from a LOCATOR string so a find/anchor that the model wrote with - * markdown (or a stray emoji) can still match the document's plain text. + * Locator normalization helpers for mcp. The two PRIMITIVES — + * `stripInlineMarkdown` (lenient locator normalizer) and `stripWrappersAndLinks` + * (strict balanced-wrapper/link collapse) — live in the canonical package + * `@docmost/prosemirror-markdown` (#493 dedup: they used to be forked verbatim + * here). This module now only re-exports `stripInlineMarkdown` and adds the two + * mcp-only helpers built on top: `stripBalancedWrappers` and `closestBlockHint`. * - * This is used ONLY as a fallback for LOCATING (after an exact match fails); - * it is never applied to replacement text or inserted node content, so no - * formatting is ever lost. + * They are used ONLY as a fallback for LOCATING (after an exact match fails) and + * for formatting-vs-plain intent detection; never applied to replacement text or + * inserted node content, so no formatting is ever lost. */ +import { + stripInlineMarkdown, + stripWrappersAndLinks, +} from "@docmost/prosemirror-markdown"; -/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */ -const MAX_PASSES = 8; +// Re-export the canonical locator normalizer so mcp call sites keep importing it +// from `./text-normalize.js` unchanged. +export { stripInlineMarkdown }; /** - * Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so - * `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is - * non-greedy and capture group 1 is the inner text. Applied repeatedly until - * the string stops changing (nested wrappers like `**_x_**`). - */ -const WRAPPER_PATTERNS: RegExp[] = [ - /\*\*([^*]+?)\*\*/g, // **x** - /__([^_]+?)__/g, // __x__ - /~~([^~]+?)~~/g, // ~~x~~ - /\*([^*]+?)\*/g, // *x* - /_([^_]+?)_/g, // _x_ - /``([^`]+?)``/g, // ``x`` - /`([^`]+?)`/g, // `x` -]; - -/** Links/images -> their visible text. `!?` covers both `[t](u)` and `![a](s)`. */ -const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g; - -/** - * Apply ONLY the two balanced/link passes shared by both normalizers: first - * collapse links/images to their visible text, then collapse balanced inline - * wrappers repeatedly until stable. Does NOT trim decoration, does NOT guard - * against an empty result — it returns exactly the transformed string. - */ -function stripWrappersAndLinks(s: string): string { - // 1. Links/images -> their visible text. - let out = s.replace(LINK_IMAGE_RE, "$1"); - - // 2. Strip balanced wrappers, repeating until the string is stable so nested - // wrappers (`**_x_**`) and adjacent runs both collapse. - for (let pass = 0; pass < MAX_PASSES; pass++) { - const before = out; - for (const re of WRAPPER_PATTERNS) { - out = out.replace(re, "$1"); - } - if (out === before) break; - } - return out; -} - -/** - * STRICT formatting detector — distinct from the lenient locator - * normalization below. It strips ONLY what unambiguously is markdown markup: - * 1. links/images `[text](url)` -> `text`, `![alt](src)` -> `alt`, and - * 2. balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers (repeat-until-stable), - * and DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone - * marker chars (the lenient extras `stripInlineMarkdown` does in its step 3). + * STRICT formatting detector — distinct from the lenient locator normalization. + * It strips ONLY what unambiguously is markdown markup (links/images to visible + * text, and balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers) and + * DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone marker + * chars (the lenient extras `stripInlineMarkdown` does). * * It exists ONLY to recognize formatting-vs-plain INTENT in `applyTextEdits` * (deciding whether find/replace differ purely by markdown markers). Because it @@ -77,44 +43,6 @@ export function stripBalancedWrappers(s: string): string { return stripWrappersAndLinks(s); } -/** - * Conservatively strip inline markdown from a locator string. - * - * Deterministic, order-fixed steps: - * 1. Links/images: `[text](url)` -> `text`, `![alt](src)` -> `alt`. - * 2. Balanced inline wrappers (strong before emphasis, code, strikethrough), - * applied repeatedly until stable for nested cases. - * 3. Trim leading/trailing decoration only: whitespace, leftover marker chars - * (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,` - * etc.) are NEVER trimmed. - * - * If the result is empty (e.g. the input was only markers like `***`), the - * ORIGINAL string is returned so a locator can never normalize down to "" and - * match everything. - */ -export function stripInlineMarkdown(s: string): string { - if (typeof s !== "string" || s.length === 0) return s; - - // 1 + 2. Shared link/image and balanced-wrapper passes. - let out = stripWrappersAndLinks(s); - - // 3. Trim leading/trailing decoration: whitespace, leftover markdown markers, - // and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the - // regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT - // Extended_Pictographic). The `u` flag enables the Unicode property escape. - // Anchored runs only — interior text and sentence punctuation are untouched. - const DECORATION = - "[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+"; - out = out - .replace(new RegExp("^" + DECORATION, "u"), "") - .replace(new RegExp(DECORATION + "$", "u"), ""); - - // 4. Never normalize a locator down to nothing. - if (out.length === 0) return s; - - return out; -} - /** * Build a bounded "closest text" hint for an anchor/find MISS, shared by * editPageText (json-edit) and createComment (client) so both surface the diff --git a/packages/prosemirror-markdown/src/lib/index.ts b/packages/prosemirror-markdown/src/lib/index.ts index 35216e65..2c5c2bf0 100644 --- a/packages/prosemirror-markdown/src/lib/index.ts +++ b/packages/prosemirror-markdown/src/lib/index.ts @@ -86,6 +86,17 @@ export type { OutlineEntry } from "./node-ops.js"; // string (#414: single copy shared by mcp and the CommonJS server app). export { parseNodeArg } from "./parse-node-arg.js"; +// Locator markdown-stripping (#493 dedup): the single canonical copy of the +// markdown-tolerant anchor-normalization primitives, imported by mcp's +// text-normalize.ts instead of a forked duplicate. `stripInlineMarkdown` is the +// lenient locator normalizer (trims stray decoration); `stripWrappersAndLinks` +// is the strict balanced-wrapper/link primitive mcp builds `stripBalancedWrappers` +// on top of. +export { + stripInlineMarkdown, + stripWrappersAndLinks, +} from "./text-normalize.js"; + // Inline-footnote authoring convention (#414: single copy, formerly the mcp // `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`. export { diff --git a/packages/prosemirror-markdown/src/lib/text-normalize.ts b/packages/prosemirror-markdown/src/lib/text-normalize.ts index ea84d648..5e5ad26c 100644 --- a/packages/prosemirror-markdown/src/lib/text-normalize.ts +++ b/packages/prosemirror-markdown/src/lib/text-normalize.ts @@ -7,13 +7,12 @@ * it is never applied to replacement text or inserted node content, so no * formatting is ever lost. * - * Scope note (#414): this package-local copy exists so `node-ops.ts` — which - * lives here now (the single canonical copy) — can resolve its markdown-tolerant - * anchor fallback without a circular dependency back on `@docmost/mcp`. It - * intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops` - * needs); the mcp-side `text-normalize.ts` (which additionally serves - * `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate - * dedup task and is left untouched here. + * CANONICAL HOME (#414/#493): this is the single source of truth for locator + * markdown-stripping. `node-ops.ts` (which lives here) uses it directly, and the + * mcp-side `text-normalize.ts` now IMPORTS `stripInlineMarkdown` and the shared + * `stripWrappersAndLinks` primitive from here (via `@docmost/prosemirror-markdown`) + * instead of keeping a drifting copy — mcp only adds its own thin + * `stripBalancedWrappers`/`closestBlockHint` on top. */ /** Maximum unwrap passes, so pathological/nested input cannot loop forever. */ @@ -44,7 +43,7 @@ const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g; * Does NOT trim decoration, does NOT guard against an empty result — it returns * exactly the transformed string. */ -function stripWrappersAndLinks(s: string): string { +export function stripWrappersAndLinks(s: string): string { // 1. Links/images -> their visible text. let out = s.replace(LINK_IMAGE_RE, "$1");