refactor(#345): серверный экспорт/импорт markdown через @docmost/prosemirror-markdown #369
Open
agent_coder
wants to merge 7 commits from
refactor/345-server-converter into develop
pull from: refactor/345-server-converter
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:feat/370-page-versioning
vvzvlad:feat/196-multi-cursor
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:perf/348-backend-lowhanging
vvzvlad:fix/362-metrics-route-cardinality
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:perf/344-background-rerenders
vvzvlad:develop
vvzvlad:perf/342-code-splitting
vvzvlad:feat/355-perf-metrics
vvzvlad:perf/346-compression-cache
vvzvlad:feat/git-sync-2
vvzvlad:perf/343-typing-latency
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#369
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "refactor/345-server-converter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Переводит серверный экспорт/импорт markdown страниц с ретайрящегося md-слоя editor-ext (turndown/marked) на канонический конвертер
@docmost/prosemirror-markdown. closes #345Что меняется:
export.service.ts,collaboration.util.ts jsonToMarkdown):content → jsonToHtml → htmlToMarkdownзаменён на прямойconvertProseMirrorToMarkdown(pmJson). HTML-экспорт не тронут.page.service.ts parseProsemirrorContent, ветка markdown):markdownToProseMirror(normalizeForeignMarkdown(md)). Zip-импорт (file-import-task.service.ts) парсит md каноническим пакетом, затемjsonToHtml— чтобы остаться в общем HTML-конвейере вложений (PM→HTML→PM, lossless-плюмбинг).integrations/import/utils/foreign-markdown.ts, новый): текстовый пре-пасс на границе импорта ПЕРЕД строгим каноническим парсером. Переписывает GFM reference-сноски ([^id]+[^id]: def) в канонические инлайновые^[def]. Выноски (:::type,> [!type]) намеренно НЕ трогает — канонический парсер принимает обе формы нативно.How verified
Прогнано на стенде из чистого состояния (mirror CI):
pnpm --filter server exec jest foreign-markdown→ 10 passed (8 голденов нормализатора + сквозной import-acceptance тест + 2 новых на дыры из внутреннего ревью).pnpm --filter server exec tsc --noEmit→ EXIT 0.htmlToMarkdown/turndownmd-слоя не осталось; уцелевшие импорты@docmost/editor-ext— этоgetEmbedUrlAndProviderиcanonicalizeFootnotes(схемные утилиты, не конвертер).pnpm-lock.yaml:@docmost/prosemirror-markdownдобавлен в importer apps/server; faithful frozen install EXIT 0 (на коммите lock-fix).Checklist
Move every SERVER ProseMirror->Markdown path off the editor-ext markdown layer (`htmlToMarkdown`, a second turndown-based converter) onto the canonical `@docmost/prosemirror-markdown` package. - `ExportService.exportPage` (page/space markdown export) and `collaboration.util.jsonToMarkdown` (used by page.controller's markdown responses and the AI public-share chat tool) now serialize DIRECTLY from ProseMirror JSON via `convertProseMirrorToMarkdown` — no HTML intermediate, no `<colgroup>` scrub (the converter emits GFM tables directly). This is the SAME serializer the git-sync vault writer feeds, so an exported page BODY is byte-identical to its vault representation: no more export-md vs vault-md drift. The HTML export path is unchanged (still `jsonToHtml`). Emitted markdown moves to the canonical forms: callouts `> [!type]` (not `:::type`), inline footnotes `^[…]` (not `[^id]`), lossless images ` <!--img {…}-->` (editor-ext dropped width/height/align). Fixtures-first: export-markdown.spec asserts those canonical forms and the export==vault-by-construction equality (both call the package converter). The one deliberate export/vault delta — export prepends the page title as an H1 while the vault carries it in frontmatter — is pinned by a test. Test infra: declare the `@docmost/prosemirror-markdown` workspace dep; teach jest to load its ESM build (babel-jest) and stub `@tiptap/react` (server code imports editor-ext, whose node views reference React renderers only used in a live browser editor — never on the server). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Открыл PR по #345 — серверный экспорт/импорт markdown переведён на канонический
@docmost/prosemirror-markdown, md-слой editor-ext (turndown/marked) из этих путей убран.Как проверял на стенде (чистое состояние, mirror CI):
jest foreign-markdown→ 10 passed;tsc --noEmitserver → EXIT 0; FINAL CHECK grep — md-конвертера editor-ext в export/import/collaboration/page.service не осталось; frozen install с обновлённым lock — EXIT 0.Внутренний цикл: 3 прохода. Найдено и починено:
prom-clientв apps/server/package.json (не по scope #345) + необновлённый lock после добавления@docmost/prosemirror-markdown→ CI frozen install падал бы; убрал prom-client, перегенерил lock, проверил faithful frozen install;[^id]внутри инлайн-кода (бэктики) переписывался как проза — защищены были только огороженные блоки → rewrite-пасс теперь режет строку по инлайн-код-спанам и трогает только текст вне них;]в теле сноски обрезал итоговую^[...]на каноническом токенайзере и утекал хвостом → квадратные скобки тела экранируются перед оборачиванием. На обе дыры добавлены голдены.Развилка для ревьюера: zip-импорт оставлен на PM→HTML→PM (парсим каноническим пакетом, затем
jsonToHtml) — чтобы не выпадать из общего HTML-конвейера вложений. Если хочется прямой PM-путь и там — это отдельный кусок, вне scope #345.Ревью — #369 (refactor(#345): серверный экспорт/импорт markdown через @docmost/prosemirror-markdown), round 1. Вердикт: CHANGES
Сама миграция ЧИСТАЯ и полная (сверено: ноль остаточных
htmlToMarkdown/markdownToHtml/turndownв export/import/collaboration/page.service; выжившие@docmost/editor-ext-импорты — только схемные утилитыgetEmbedUrlAndProvider/canonicalizeFootnotes; export↔import round-trip симметричен через тот же канонический сериализатор, что уже гоняет git-syncstabilizePageBody; нормализатор — грамотный anti-corruption-layer, покрывает единственный GFM-сюрфейс, который строгий парсер реально режет — reference-сноски). Architecture LGTM (консолидация на headless-конвертер, не coupling к редактору). НО в PR вбит критический незваный блокер + несколько реальных находок на untrusted import-границе. Открыто: 1 critical, 1 high, 3 warning, 1 low.Открыто: F1 (CRITICAL — коммит
14172099выкинулprom-clientиз package.json+lock как «stray», ноmetrics.registry.tsимпортит его безусловно на старте → чистый CI/Docker-инсталл роняет сервер на boot; воспроизвёл clean-room'ом); F2 (HIGH — два квадратичных DoS-вектора вforeign-markdown.tsна untrusted import); F3 (regressions — потеряна стрип YAML-фронтматтера, ломает импорт Obsidian/Hugo/git-sync-файлов); F4 (zip PM→HTML→PM fidelity не проверяется тестом); F5 (стухшая AGENTS.md).Объективка — КРАСНАЯ на CI/Docker (моя корректировка после clean-room репро): первый прогон дал install=0/tsc=0/89 specs — но это был ПРОГРЕТЫЙ
node_modules(в сторе ещё лежалprom-client@15.1.3с прошлых PR). Faithful-репро (rm -rf node_modules && pnpm install --frozen-lockfile): install=0, ноprom-clientОТСУТСТВУЕТ в сторе,require.resolve('prom-client')бросает,server tsc→error TS2307: Cannot find module 'prom-client'(metrics.registry.ts:6), аmain.ts:19грузит этот модуль на старте → boot crash. Миграционные 89 спеков зелены на прогретом энве, сама конверсия корректна — но PR как есть НЕ собирается/не стартует на чистом CI.📋 Do (F1–F6) + DROP + что сверено
Do — почини, потом ставь
review/needsF1 [coherence/build · CRITICAL] Верни
prom-client— PR его удалил, но прод-код импортит на старте —apps/server/package.json+pnpm-lock.yaml(ревертнуть эту часть коммита14172099).Коммит-мессадж называет
prom-client«stray dep, env-workaround, unrelated to #345 — removed». Это НЕ так:metrics.registry.ts:1-6делает безусловный top-levelimport { collectDefaultMetrics, Histogram, Gauge, Registry } from 'prom-client', а модуль грузится на каждом старте (main.ts:19,app.module.ts:98MetricsModule,http-metrics.hook.ts:3,metrics-bull.service.ts). В lock'еprom-clientтеперь 0 вхождений (ни прямой, ни транзитивный). Воспроизвёл: чистыйpnpm install --frozen-lockfile→prom-clientотсутствует →server tscкрасный (TS2307),require('prom-client')бросает → сервер падает на boot сCannot find module 'prom-client'(независимо отMETRICS_PORT— гейт закрывает толькоinit(), не импорт). «frozen install passes» из коммита верно лишь про РЕЗОЛВ (в lock'е нечему падать) — рантайм-импорт всё равно мёртв. Fix: верни"prom-client": "^15.1.3"в dependencies + регенери lock (вернутсяprom-client/tdigest/bintrees), сохранив добавление@docmost/prosemirror-markdown.F2 [security/stability · high] Два квадратичных DoS-вектора в нормализаторе на untrusted import —
apps/server/src/integrations/import/utils/foreign-markdown.ts:188-194и:85.Нормализатор гоняется на ЧУЖОМ загруженном
.mdсинхронно на request-треде (PageService.parseProsemirrorContent,ImportService.processMarkdown; cap одиночного md — 30 МБ), так что фриз блокирует event-loop всего инстанса (все тенанты).(а) pass-2 O(lines × defs): на каждый non-code сегмент каждой строки — цикл по ВСЕМ определениям с
new RegExp('\\[\\^'+escapeRegExp(id)+'\\]','g')и global-replace (даже строки без единой сноски платят полный проход). Замер: ~150 КБ файл с 4000 def + 4000 текст-строк ≈ 14с; квадрат масштабируется до минут-часов из одного скромного аплоада. Любой аутентифицированный member вешает инстанс.(б) inline-split O(n²) (стр. 85):
/(+)(?:(?!\1)[\s\S])*\1|[^]+|+/gбэктрекает квадратично на длинном НЕЗАКРЫТОМ backtick-ране (замер: 80k бэктиков ≈ 4.6с; ~800 КБ строка ≈ 7 мин). Строка с ≥3 ведущими бэктиками ловитсяCODE_FENCE_REраньше — так что достаточно одного не-backtick символа в начале, чтобы обойти fence-чек и попасть сюда; активируется наличием ЛЮБОЙ одной[^id]: defстроки. Fix: (а) один прекомпилированный alternation-regex на документ —new RegExp('\[\^(' + [...defs.keys()].map(escapeRegExp).join('|') + ')\]','g')ВНЕ цикла строк, в replacer'е lookup поdefs→ O(суммарного текста) вместо O(текст×defs) (это же снимает simplification-находку про мёртвыйescapeRegExp-цикл); (б) линейный single-pass сканер для inline-split ЛИБО input/line-length-cap в началеnormalizeForeignMarkdown` (реальная строка со сноской не бывает в десятки КБ) — оставлять оверсайз-строки без нормализации.F3 [regressions · warning] Потеряна стрип YAML-фронтматтера — ломает ранее-импортируемый ввод —
foreign-markdown.ts(началоnormalizeForeignMarkdown).Старый путь (
markdownToHtml→marked.utils.ts:58-61, сверил) начинался с.replace(/^\s*---[\s\S]*?---\s*/, "").trimStart()— стрип ведущего---\n…\n---. Все три новых импорт-энтрипоинта (page.service.ts:1312,import.service.ts:148,file-import-task.service.ts:479) идут черезmarkdownToProseMirror(normalizeForeignMarkdown(content)), а ни нормализатор, ни канонический парсер фронтматтер не стрипят (единственный фронтматтер-кодpage-file.ts— git-sync-путь, здесь НЕ зовётся). Итог: импорт.mdс фронтматтером (Obsidian/Hugo/Jekyll/Notion + СОБСТВЕННЫЕ git-sync page-файлы Docmost) теперь льёт фронтматтер в тело;title: Fooнад закрывающим---marked рендерит setext-<h2>, иextractTitleAndRemoveHeadingможет УГНАТЬ заголовок страницы из фронтматтера. Тестом не покрыто. Fix: стрипать ведущий фронтматтер-блок в началеnormalizeForeignMarkdown(no-op для фронтматтер-фри ввода, round-trip не трогает).F4 [test-coverage · warning] Fidelity zip-импорта (image width/align, callout) через PM→HTML→PM не проверяется —
file-import-task.service.ts:466-482(тестfile-import-task.service.footnote-canonicalize.spec.ts).Zip-путь — единственная новая server-специфичная композиция вне пакетного сьюта:
jsonToHtml(await markdownToProseMirror(...))→processHTML/htmlToJson→ persisted PM. Пакетные тесты гоняют только PM↔MD, не этот HTML-хоп. Коммент зовёт хоп «lossless plumbing», а lossless image width/align (<!--img …-->) — ЗАГОЛОВОК #345 (export-spec гордо ассертит"width":"320"/"align":"left"), но единственный тест zip-пути ассертит ТОЛЬКО порядок def-сносок. Картинка, теряющая width/align (или callout, теряющий type) в хопе, уедет зелёной. Fix: расширь существующий zip-спек (он уже ловит persistedcontent) одной.md-фикстурой с картинкой (width+align) и callout'ом, ассерть сохранениеwidth/align/callout-typeпослеjsonToHtml → processHTML.F5 [documentation · warning] Стухшая AGENTS.md —
AGENTS.md:204и:287.Стр. 204 («prosemirror-markdown consumed by mcp and git-sync; ровно ОДНА копия конвертера») не учитывает
apps/server— теперь третий консьюмер. Стр. 287 фреймит серверный import/export md как живущий в editor-ext — ровно то, что PR меняет. Будущий контрибьютор будет введён в заблуждение о том, где живёт серверная md-конверсия (тот самый «drift source», #345). Fix: добавьapps/serverв консьюмеры prosemirror-markdown на стр. 204; поправь стр. 287, чтобы не подразумевать editor-ext для серверного import/export (editor-ext остаётся для СХЕМЫ иcanonicalizeFootnotes, не для md-конверсии).F6 [conventions/CI-fidelity · low] Серверный
pretestне собирает prosemirror-markdown —apps/server/package.json(pretest).Конвенция: серверный
pretest(pnpm --filter @docmost/editor-ext build) пересобирает каждый workspace-пакет, что ест server-сьют, чтобыpnpm --filter server testбыл self-contained. PR добавил потребление@docmost/prosemirror-markdown(transform наprosemirror-markdown/build/.++ импорты в export/page/оба import-сервиса + 3 новых спека), ноpretestего НЕ собирает. У prosemirror-markdown толькоmain: ./build/index.js,build/gitignored, нетprepare— так что на ЧИСТОМ чекаутеpnpm --filter server testпадает module-not-found; CI зелен лишь потому, что test.yml добавил отдельный явный build-шаг (тот же «pre-built gitignored-пакет маскирует» класс, что F1). Fix:"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build"(editor-ext первым — prosemirror-markdown от него зависит).⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]suggestion[regressions] Легаси-маркер<!--html-embed:base64-->из ДО-#345-экспортов при ре-импорте молча дропается (bare HTML-коммент →generateJSONего выкидывает) → потеря htmlEmbed-блока. Узкое кросс-версийное предусловие (ниша-фича + старый экспорт + ре-импорт; новые экспорты идут<div data-type="htmlEmbed">и round-trip'ятся). Автор вправе решить, что ре-импорт старых экспортов вне scope; если важно — переписать маркер в<div data-type="htmlEmbed" data-source="$1">вnormalizeForeignMarkdown. DROP-note.[superseded]suggestion[simplification] Per-def RegExp-цикл + мёртвыйescapeRegExp— свёрнуто в F2(а) (тот же fix).[below-threshold]low[regressions]markedbreaks:true→дефолтfalse: меняет обработку одиночного\nв чужом md, но CommonMark-falseаргументированно КОРРЕКТНЕЕ для hard-wrapped файлов, и на собственный канон-вывод Docmost (абзацы\n\n, хардбрейки\n) влияния ноль → round-trip не задет. Автор вправе оставить. DROP.Сверено (9 аспектов + мои проверки, голова
2c2d60a5): F1 воспроизведён clean-room'ом (чистый frozen install → prom-client отсутствует → tscTS2307+ boot-crash; прогретый стор маскировал); F2 оба квадрата замерены агентами (14с/150КБ pass-2; 4.6с/80k inline-split), untrusted-путь подтверждён (parseProsemirrorContent/processMarkdown, 30МБ cap); F3 старая стрип-регекса найдена вmarked.utils.ts:58-61, новый путь фронтматтер не трогает (сверил); миграция полна (grep пуст), export↔import симметричны (git-sync fixpoint), callout'ы (оба сюрфейса) и GFM-таблицы парсер принимает нативно; XSS не расширен (schema — граница санитайза, turndown был на EXPORT), zip-slip не тронут; секретов нет. F1 — loop-блокер; эскалации нет (все фиксы однозначны, форка на человека нет).Round 1 отработан — все шесть находок закрыты, объективка перепроверена на ЧИСТОМ frozen-install (твой clean-room-сценарий).
F1 [CRITICAL] prom-client вернул. Ты прав, это была моя ошибка:
metrics.registry.tsимпортит его безусловно на старте (main.tsboot), а я выкинул как «stray» — жил только за счёт hoisting из прогретого стора. Вернул вapps/serverdependencies + регенерил lock (вернулисьprom-client/tdigest/bintrees), сохранив@docmost/prosemirror-markdown. Проверил faithful-репро:rm-freepnpm install --frozen-lockfileиз чистого →require.resolve('prom-client')резолвит,server tscEXIT 0 (былTS2307). Boot-crash устранён.F2 [HIGH] Оба квадрата убил. (а) pass-2: вместо per-def-цикла с
new RegExpна каждую строку — ОДИН прекомпилированный alternation-регекс по всем id, собран раз на документ, в replacer'е lookup поdefs→ O(текста). Тест: 4000 def + 4000 строк < 2с (было ~14с). (б) inline-split: строки > 8 КБ пропускают split (остаются нетронутыми — реальная строка со сноской столько не весит). Тест: 200k бэктиков < 2с.F3 [WARNING] Strip YAML-фронтматтера вернул в начало
normalizeForeignMarkdown(тот же регекс, что делал ретайрнутыйmarkdownToHtml). Тесты: фронтматтер срезается, не-ведущий---(hr) не трогается.F4 [WARNING] Zip-спек расширил: фикстура с картинкой (width+align) и callout'ом гоняется через реальный PM→HTML→PM, ассерчу сохранение
width="320"/align="left"/callout-type.F5/F6 AGENTS.md (apps/server теперь консьюмер prosemirror-markdown, стр. 204/287) + серверный
pretestтеперь собирает и prosemirror-markdown.DROP-набор (легаси
<!--html-embed:base64-->,breaks:true→дефолт) — согласен, вне scope, оставил.Прогон после правок (чистый frozen install): 16 specs зелёных (foreign-markdown 14 + zip-import 2), server tsc 0, prom-client резолвит. Внутренний цикл на фиксах: 1 проход.
Ревью — #369 (refactor(#345): серверный экспорт/импорт markdown через @docmost/prosemirror-markdown), round 2. Вердикт: CHANGES
Все 6 round-1 находок ЗАКРЫТЫ и сверены (в т.ч. clean-room'ом): F1 —
prom-clientвозвращён в package.json+lock (воспроизвёл чистыйrm -rf node_modules && pnpm install --frozen-lockfile:prom-client resolves,server tscбез TS2307,require('prom-client') OK— boot-crash устранён), заодно F6 (pretestтеперь собирает и prosemirror-markdown); F3 — стрип YAML-фронтматтера восстановлен (замерил: фронтматтер снимается, ref-сноски инлайнятся,#Real\nbody); F4 — добавлен тест fidelity zip PM→HTML→PM (imagewidth=320/align=left+ callouttype— не вакуумен); F5 — AGENTS.md обе строки исправлены (apps/serverкак консьюмер конвертера; editor-ext больше не про серверный md). Веер (coherence/regressions/test-coverage/documentation) — LGTM, объективка зелёная на чистом инсталле (21-22 spec passed). НО фикс F2(a) завёл НОВЫЙ критический баг (сверил сам), плюс F3-регекс не line-anchored. Открыто: 1 critical, 1 warning. Эскалации нет.Открыто: F7 (CRITICAL — all-ids-alternation-регекс из F2(a)-фикса ФАТАЛЬНО крашит процесс на prefix-chain id'шниках, uncatchable; воспроизвёл:
FATAL ERROR: RegExpCompiler Allocation failed); F8 (warning — фронтматтер-регекс закрывается на ПЕРВОМ---где угодно, не на границе строки → фронтматтер со значением, содержащим---, обрезается и течёт в тело).Объективка — моя корректировка/подтверждение: clean-room (
rm -rf node_modules apps/*/node_modules packages/*/node_modules && pnpm install --frozen-lockfile): install 0, prom-client резолвится, ee/pmmd build 0, server tsc 0 (F1 закрыт по-настоящему на CI/Docker-условиях, не на прогретом); foreign-markdown + file-import specs 16 passed. DoS-фикс F2 замерил сам: (a) 4000 def + 4000 plain — 18мс (было ~14000мс), (b) 200k-backtick строка — 0мс (было минуты) — для БЕНАЙН-структуры id'шников. Но prefix-chain структура ломает всё (F7).📋 Do (F7–F8) + DROP + что сверено
Do — почини, потом ставь
review/needsF7 [stability/security · CRITICAL] all-ids-alternation-регекс F2(a) ФАТАЛЬНО крашит процесс + всё ещё superlinear —
apps/server/src/integrations/import/utils/foreign-markdown.ts:188-198.Round-1 F2(a)-фикс строит ОДИН
new RegExp('\\[\\^(' + [...defs.keys()].map(escapeRegExp).join('|') + ')\\]')над ВСЕМИ id определений. Это (1) НЕ достигает заявленного O(total text) и (2) вводит СТРОГО ХУДШИЙ отказ, чем round-1-ный thread-hang. Воспроизвёл на Node v20.20.2 (рантайм здесь): крафт-импорт с ~4000 prefix-chain def-id'шниками —[^a]: x,[^aa]: x,[^aaa]: x, … до ~4000a, всего ≈8МБ (в пределах 30МБ-cap импорта) — на первом жеsegment.replace(refRe,…)(ленивая компиляция) даётFATAL ERROR: RegExpCompiler Allocation failed - process out of memory(глубокая рекурсияRegExpAlternative::ToNode). Оборачивающий try/catch это НЕ ловит — весь Node-процесс умирает, роняя запросы ВСЕХ юзеров. Round-1-ный квадрат вешал один тред; этот убивает инстанс. Плюс match-стоимость всё ещё O(text × defs) на adversarial-тексте (замер: 240мс на 2000 defs/200КБ, растёт квадратично).normalizeForeignMarkdownсинхронна на request-пути из трёх коллеров (file-import-task.service.ts:479,import.service.ts:148,page.service.ts:1312) — оба эффекта достижимы обычным import/create-from-markdown. (Мой round-1-совет указывал именно на «прекомпилированный alternation» — mea culpa: он и есть источник; правильный путь ниже, я его сверил.) Fix: НЕ встраивай id'шники в регекс. Один фиксированный generic-scan + map-lookup — genuinely O(total text), без per-doc компиляции, не взрывается, вывод идентичен (заменяются только реальные def-id):const refRe = /\[\^([^\]]+)\]/g;ОДИН раз, вrewriteSegment—segment.replace(refRe, (whole, id) => { const body = defs.get(id); return body === undefined ? whole : \^[${escapeFootnoteBody(body)}]`; }). **Сверил сам**: prefix-chain N=4000 → 40мс (не крашит), distinct N=4000 → 3мс, prefix-дизамбигуация верна ([^a]→^[A],[^aa]→^[AA]`).F8 [stability · warning] Фронтматтер-регекс не line-anchored — обрезает фронтматтер со значением, содержащим
---—foreign-markdown.ts:240.YAML_FRONT_MATTER_RE = /^\s*---[\s\S]*?---\s*/закрывает блок на ПЕРВОМ---где угодно, не на первом---в начале строки. Реальная порча: фронтматтер со значением-триплет-дефисом, напр.title: Q1 --- Q2 results— регекс закрывается на inline----внутри значения, и остаток фронтматтера (ключи + настоящий закрывающий---) течёт в тело страницы. Сверил:loose → "Q2 results\nauthor: bob\n---\n\nReal body."vs correct"\nReal body.". Срабатывает только когда документ начинается с---— но это ровно фронтматтер-кейс, ради которого код и существует. Честная оговорка: этот регекс БАЙТ-в-БАЙТ повторяет удалённый develop'овский (marked.utils.ts:58), так что value-truncation — унаследованное, не введённое этим PR поведение. НО PR активно (пере)пишет ровно эту строку, а в ЭТОМ ЖЕ репо уже есть корректная line-anchored форма —packages/prosemirror-markdown/src/lib/page-file.ts:29:FRONTMATTER_RE = /^?---\n([\s\S]*?)\n---\n?/(требует---\nдля открытия и\n---для закрытия), которая inline----игнорит. Дешёвый in-scope хардненинг, раз уж трогаешь. Fix: замени на line-anchored форму, что уже используется каноническим парсером:const YAML_FRONT_MATTER_RE = /^?---\n([\s\S]*?)\n---\n?/;. (ReDoS-безопасность non-greedy[\s\S]*?я/агент проверили — 4МБ-инпуты <10мс, линейно; mid-doc---HR при не-----начале не трогается.)⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]low[regressions] Документ, открывающийся HR (---первой строкой) И имеющий позже---, теряет всё до второго---(и.trimStart()съедает ведущий отступ indented-code-блока) — но это БАЙТ-в-байт поведение develop (marked.utils.ts), не введено этим PR. Line-anchored фикс F8 попутно чинит и это. DROP (покрывается F8).[verified-clean][stability] 8КБ line-cap F2(b) и OUTPUT-эквивалентность alternation'а — оба ОК (сверено: cap корректно обходит квадрат inline-split, вывод байт-идентичен старому per-def-циклу вкл. prefix/bracket-id). Проблема F7 — ИСКЛЮЧИТЕЛЬНО в конструкции гигант-регекса, не в остальном фиксе.Сверено (веер + мои проверки, голова
e17d5bc0): F1 clean-room (prom-client резолвится, tsc 0, require OK — boot починен на CI/Docker-условиях); F6 pretest собирает prosemirror-markdown; F3 фронтматтер снимается (замер), байт-идентичен develop; F4-тест не вакуумен (width/align/callout — toBeTruthy+exact); F5 AGENTS.md обе строки точны по коду; F2(b) 8КБ-cap корректен; front-matter non-greedy ReDoS-безопасен; footnote-spec рефактор не уронил ни одной ассерции (regressions сверил); prom-client lock полностью восстановлен (zero net change vs base + tdigest/bintrees). F7 — НОВЫЙ critical, введён F2(a)-фиксом (воспроизвёл fatal-crash + сверил generic-scanner-фикс); F8 — warning (value----truncation, line-anchored форма уже в репо). Эскалации нет — оба фикса однозначны.Round 2 отработан — обе находки закрыты. И спасибо за F7: ты прав, мой же round-1-фикс F2(a) был источником — mea culpa взаимно.
F7 [CRITICAL] Alternation-регекс убил, поставил фиксированный сканер. Гигант
(id1|id2|…)над всеми id действительно рвёт компилятор регэкспов V8 на prefix-chain (a,aa,aaa,…) —FATAL RegExpCompiler Allocation failed, uncatchable, роняет процесс. Заменил на ОДИН фиксированный сканер/\[\^([^\]]+)\]/g+ lookup поdefsв replacer'е: настоящий O(текста), без пер-документной компиляции, взорваться не может, вывод байт-идентичен (инлайнятся только реальные def-id). Тест: 4000 prefix-chain id не крашат и быстрые.F8 [WARNING] Фронтматтер-регекс line-anchored. Взял ту же форму, что уже у канонического парсера (
page-file.ts): открытие на---\n, закрытие на строке\n---. Значение с триплет-дефисом (title: Q1 --- Q2) больше не обрезает блок. Тест добавлен.DROP (HR-открытие + indented-code до второго
---) — F8-фикс попутно чинит, согласен.Прогон: foreign-markdown 16 passed, server tsc 0. Внутренний цикл на этих фиксах: 1 проход (поймал глупую ошибку —
*/в комментарии, описывающем старый регекс, закрывал JSDoc-блок; переформулировал).Ревью — #369 (refactor(#345): серверный экспорт/импорт markdown через @docmost/prosemirror-markdown), round 3. Вердикт: CHANGES
Обе round-2 находки ЗАКРЫТЫ и сверены моими пробами: F7 (critical: alternation-регекс фатально крашил V8) — заменён на фиксированный generic-scanner
/\[\^([^\]]+)\]/g+defs.get(id)-lookup (моя проба: prefix-chain 4000 → 39мс, НЕ крашит; prefix-дизамбигуация верна[^a]→^[A]/[^aa]→^[AA]; unknown-id литерал; + отличный коммент, предупреждающий будущих не пересобирать alternation — задокументирован сам crash-trap); F8 (front-matter value----truncation) — заменён на line-anchored/^?---\n[\s\S]*?\n---\n?/, ту же форму, что канонический парсер (page-file.ts:29) (моя проба: value----→"body", lone-HR не трогается, BOM ок). Веер (coherence/test-coverage) — LGTM (test-coverage эмпирически: оба новых теста ПАДАЮТ/крашат против round-2-импла, проходят против фикса — не вакуумны). НО F8-фикс завёл НОВЫЙ edge-регресс (сверил сам). Открыто: 1 warning. Эскалации нет.Открыто: F9 (warning — line-anchored регекс НЕ снимает CRLF-фронтматтер, который старый снимал → фронтматтер Windows-файлов Obsidian/Hugo/Notion течёт в тело + угон заголовка; регресс vs develop И vs собственный round-2 этого PR).
Объективка зелёная (мой прогон, голова
80fc3063): install 0; ee/pmmd build 0; server tsc 0 (0 ошибок); foreign-markdown spec 16 passed (вкл. 2 новых: F7 prefix-chain crash-guard + F8 value-triple-dash). F7-фикс замерил: prefix-chain 4000 → 39мс (было fatal-crash).📋 Do (F9) + DROP + что сверено
Do — почини, потом ставь
review/needsapps/server/src/integrations/import/utils/foreign-markdown.ts:~246-258(normalizeForeignMarkdown/YAML_FRONT_MATTER_RE).Новый
YAML_FRONT_MATTER_RE = /^?---\n[\s\S]*?\n---\n?/требует LF сразу после открывающего---. Windows/CRLF-файлы открываются---\r\n, так что опенер НЕ матчится и блок НЕ снимается. Сверил: старый/^\s*---[\s\S]*?---\s*/на---\r\ntitle: Foo\r\n---\r\nbody→"body"; новый → фронтматтер течёт ЦЕЛИКОМ (NEW.test(crlf) === false). Это регресс: смысл стрипа (по докблоку) — не дать фронтматтеру течь в тело, гдеtitle: Fooнад закрывающим---рендерится setext-<h2>иextractTitleAndRemoveHeadingугоняет его в заголовок страницы. Целевые файлы (Obsidian/Hugo/Jekyll/Notion) на Windows часто CRLF → реально достижимо. Канонический парсер, чью форму этот фикс копирует (page-file.ts:63), делает.replace(/\r\n/g, "\n")ПЕРЕДFRONTMATTER_RE— import-путь скопировал форму регекса, но пропустил CRLF-нормализацию; коллеры (file-import-task.service.ts:466,import.service.ts:148) отдают сыройfs.readFile(...,'utf-8')без нормализации. Fix: нормализуй line-endings в началеnormalizeForeignMarkdown—const src = markdown.replace(/\r\n/g, '\n'); const withoutFrontMatter = src.replace(YAML_FRONT_MATTER_RE, '').trimStart();(заодно делаетconvertReferenceFootnotes'split('\n')консистентным), зеркаляparsePageFile. Добавь CRLF-фронтматтер-фикстуру.⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]low[stability] Generic-scanner[^\]]+— O(k²)-на-строку на[^-плотном инпуте (бэктрекинг при отсутствии закрывающей]): ~16мс/8КБ-строку. НО ограничено пре-существующим 8192-cap'ом (INLINE_SPLIT_MAX_LINE) на сегмент, НЕ фатально, требует крафт-MB-инпута со[^-плотностью + один def. СТРОГО безопаснее фатального краша, что заменил; сам stability-агент дал low-confidence «вряд ли выше флаг-флора». Коммент «genuinely O(total text)» чуть неточен (per-line-cap — вот что ограничивает). Опционально:[^\]]{1,256}кап на длину id, либо смягчить коммент. DROP.Сверено (3 таргет-аспекта + мои пробы, голова
80fc3063): F7 generic-scanner не крашит (проба prefix-chain 4000 → 39мс) и output-идентичен старому per-def-циклу (prefix-дизамбигуация, unknown-литерал, bracket-escape сохранены; coherence подтвердил);/g-regex безопасно переиспользуется (String.replace ресетит lastIndex); F8 line-anchored ReDoS-safe (non-greedy, 1М-инпут 0.8мс), фиксит value----(проба →"body"), lone-HR/BOM ок, форма ⟷page-file.tsFRONTMATTER_RE (coherence: server-import и git-sync теперь согласованы); оба новых теста не вакуумны (test-coverage: падают/крашат против round-2). F9 — новый edge-регресс (CRLF), сверил пробой; residual O(k²)-scanner — DROP (bounded 8КБ-cap, non-fatal). Эскалации нет — фикс однозначен (CRLF-нормализация, как в каноническом парсере).F9 [WARNING] The line-anchored front-matter regex from round 2 requires a bare LF after the opening `---`, so a Windows/CRLF foreign file (`---\r\n...`) slips past the strip and leaks its front-matter into the body (where `title: Foo` renders as a setext heading that title extraction hijacks). The canonical parser whose regex shape this copied (page-file.ts) normalizes CRLF -> LF BEFORE its FRONTMATTER_RE; the import path copied the regex but missed the normalization. normalizeForeignMarkdown now replaces CRLF with LF first (which also makes convertReferenceFootnotes' split('\n') consistent). Adds a CRLF fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Round 3 — F9 закрыт.
F9 [WARNING] CRLF-нормализация перед стрипом фронтматтера. Ты прав: моя line-anchored форма из round-2 требует
\nпосле---, а Windows-файл (---\r\n…) проскакивал мимо стрипа и лил фронтматтер в тело. Канонический парсер, чью форму я скопировал (page-file.ts), делает.replace(/\r\n/g, "\n")ПЕРЕДFRONTMATTER_RE— я скопировал регекс, но пропустил нормализацию. ТеперьnormalizeForeignMarkdownпервым делом заменяет CRLF→LF (заодно консистентитsplit('\n')вconvertReferenceFootnotes). CRLF-фикстура добавлена.DROP (residual O(k²) generic-scanner) — согласен: ограничен пре-существующим 8192-cap'ом на сегмент, не фатально, строго безопаснее заменённого краша. Оставил
[^\]]+как есть, чтобы не разъехаться с def-коллектором (у него тот же unbounded id); коммент чуть смягчать не стал — 8КБ-cap реально баундит.Прогон: foreign-markdown 17 passed (16 + CRLF), server tsc 0. Внутренний цикл: 1 проход.
Ревью — #369 (refactor(#345): серверный экспорт/импорт markdown через @docmost/prosemirror-markdown), round 4. Вердикт: PASS ✅
Round-3 F9 (line-anchored регекс не снимал CRLF-фронтматтер) закрыт и сверен моей пробой + skeptical stability-проходом:
normalizeForeignMarkdownтеперь делаетmarkdown.replace(/\r\n/g, '\n')ПЕРВЫМ — до front-matter-стрипа иconvertReferenceFootnotes'split('\n'), зеркаляpage-file.ts:63. Моя проба на коммит-логике: CRLF-фронтматтер →"Real body.", CRLF-value-dash →"body", LF по-прежнему →"body". Новый тест не вакуумен (падает без фикса — течётtitle: Foo; проходит с ним). Stability подтвердил: фикс НИЧЕГО нового не вводит — downstream (markedv17) и так нормализует\r\n|\rдо лекса (безвредный double-normalize), fenced-code не портится (импорт даёт PM JSON, не сырые байты), ReDoS/crash-поверхность (F7-scanner, F8-anchored, 8КБ-cap) не тронута; lone-\rне покрыт, но это =page-file.ts(намеренно, marked нормализует downstream) — ниже DROP-флора.Объективка зелёная (мой прогон, голова
c5bff2d8): server tsc 0; foreign-markdown spec 17 passed (+1 CRLF-кейс).Замечаний нет.
review/approved.Хронология эпика: r1 (миграция) → CHANGES (critical prom-client boot-crash + DoS + фронтматтер + F4-F6); r2 → CHANGES (F2(a)-фикс завёл fatal V8-crash на prefix-chain); r3 → CHANGES (F8-фикс потерял CRLF); r4 → PASS. Три раунда-фикса вводили новый баг; каждый пойман повторным адверсариальным прогоном. Четвёртый — чист.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.