refactor(mcp): дедуп stripInlineMarkdown — единый источник в каноническом пакете

Локаторная нормализация 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 18:14:39 +03:00
parent 9e95412695
commit 047433595e
3 changed files with 39 additions and 101 deletions
+21 -93
View File
@@ -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
@@ -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 {
@@ -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");