CtrlK
BlogDocsLog inGet started
Tessl Logo
SkillPublic

markdown-round-trip

Check that every small Markdown source parses, serializes and parses again to the same document, and that a streamed parse that continues its previous result equals a fresh parse at every chunking, over the plugins of the Markdown test editor. Use before and after changing packages/platejs/src/markdown/lib or another file in its coverage record, when the markdown-round-trip step of pnpm check fails, or when fixing a Markdown or streaming bug a seed or a tester found.

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

Markdown round trips and streamed parses

Handle $ARGUMENTS. check, or no argument, runs the gate bound. deep runs --inline=3 --part=round-trip and --blocks=3 and compares their failures with Findings. lower runs --lower after a fix removes a known finding. case <identity> runs --case=<identity> to print one case's full detail. update brings the verification in line with changed code, as Coverage record says.

The property. Two rules hold for every source up to the bound. A source that editor.api.markdown.parse accepts serializes, and the serialized Markdown parses to the same document. A streamed parse with parseSlice that continues a previous preview of the same source gives the result a fresh parseSlice of that prefix gives, at every chunking. docs/vision/plate.md says streaming continues the prior result with previous, and that the Markdown runtime owns reuse and invalidation so consumers keep no parse cache. Reuse that changed the result would break that promise.

The harm. A round trip that changes the document loses content each time a user saves and reopens Markdown, or each time an AI reply goes through the codec. A streamed preview that differs from a fresh parse shows the user a document that changes when the stream ends, or a final document that differs from the source. Each Markdown fix before this check, such as e1fb01295e, covered only the cases someone had already found.

What it checks

packages/platejs/scripts/markdown-round-trip.ts runs the real codec of createTestEditor in packages/platejs/src/markdown/lib/__tests__/createTestEditor.tsx. That editor has the plugins the Markdown stress cases use. The script checks the TypeScript code itself, not a model of it.

  1. Inline contents. Every string of up to --inline tokens from 22 tokens: a, a space, *, **, _, a backtick, ~~, [a](b), <br/>, <u>, </u>, a backslash, a newline, $, !, #, <, &, https://a.b, [^1], | and :+1:.
  2. Block forms. 21 forms: paragraph, ATX heading, setext heading, quote, bullet, ordered and task items, table, fenced and indented code, math, div, callout, link reference, link definition, footnote definition, a lazy list item, thematic break, bold, link and a trailing break. 15 forms take every inline content. The setext heading, lazy list item, thematic break, bold, link and trailing break are fixed sources. Eight more fixed sources hold a URL that cannot stay a bare URL or that the parse keeps as text. https://a.b.\na and https://a.b)\na put a bare URL before a line break, and <https://a.b'> a, <https://a.b"> a and <https://a.b;> a end a URL in a character that GFM trims from a bare URL. The link plugin refuses the URL of <https://a.b]> a, so the parse keeps it as text. The parse also keeps <div>www.a.b </div> and <div>a@b.cd </div> as text. Each must parse again as text, though GFM finds an autolink literal in it. They run alone and join no sequence, because as block forms they would almost double the 902 round-trip sequences. Two more forms leave a tag open that a count of tag names reads as closed, <callout> with </callout> in a code span and </callout> <callout>. They join only the streamed sequences.
  3. Sequences. Every list of up to --blocks forms, each holding a, joined by one or two newlines. The round trip runs 902 sequences at the gate and the stream 1,080.
  4. Round trip. Each form with each inline content, and each sequence, goes through parse, serialize and parse again under the default policy. A parse that refuses the source counts in the summary's refused and is not a failure. The script compares documents after nameDocumentListIds, because each parse gives every list a new id. A source that holds a line ending is also parsed with each one written as CRLF and then as CR, and each parse must give the document of the LF form.
  5. Streaming. For each sequence the script parses every prefix fresh, as a preview (lossPolicy: 'allow', partial: true) and as a strict parse. Then it continues each reachable preview to its own prefix and every longer one, as a preview and as a strict parse, and compares each with the fresh result. A continuation reads its previous result through the runtime's checkpoint. Every stored state is a preview of a prefix of the same source, so the checkpoint's editor, formats, limits, policy and source test are the same for all of them, and each segment's nodes, diagnostics and last root follow from the segment ends. So the script records each checkpoint, merges previews whose segment ends match, and still covers every chunking without walking each chain. A failed preview merges with a checkpoint that has no segments, because neither reuses anything. When a preview has no recorded checkpoint, or a segment has no whole-number end, the script exits 2, because it can no longer read markdownCheckpoints in packages/platejs/src/markdown/lib/internal/markdownConversion.ts.
  6. Rules. A case records its first failure per rule:
    • markdown-line-endings: a source whose CRLF or CR line endings parse to another document than its LF line endings.
    • markdown-round-trip: a source whose parse, serialized and parsed again, gives another document.
    • markdown-serialize: a parsed document that does not serialize, or whose Markdown does not parse.
    • markdown-stream: a preview that continues an earlier preview and differs from a fresh preview.
    • markdown-stream-final: a strict parse that continues a preview and differs from a fresh strict parse.
    • markdown-throws: a parse or serialize that throws.
  7. It compares the findings with packages/platejs/scripts/markdown-round-trip-baseline.json through tooling/scripts/finding-baseline.mjs. Every finding is filed under packages/platejs/src/markdown/lib. An identity is the source alone, and the detail names the chunking, because which chain first reaches a state changes whenever the codec's segments do.

Run it

Run every command from the repository root.

  • pnpm check:markdown-round-trip checks the gate bound: two inline tokens and two blocks. It compares 9,500 cases, 8,420 round trips and 1,080 streamed sources, in 3 to 17 s and 49 to 82 CPU-s, depending on the machine's load. It prints one JSON summary line, then each finding the baseline does not list. pnpm check markdown-round-trip runs the same step through the check runner, and pnpm check --affected runs it for a change under packages/platejs/src/ or packages/plitejs/src/, to the script or its baseline, to packages/plitejs/scripts/check-harness.ts, to config/plite-source-aliases.ts, to tooling/scripts/finding-baseline.mjs, or to a dependency through pnpm-lock.yaml.
  • --inline and --blocks raise one part of the bound, and --part=round-trip or --part=stream runs one half. --inline=3 --part=round-trip compares 166,272 cases in 303 to 437 CPU-s, and --blocks=3 compares 95,036 cases. Its stream half, --blocks=3 --part=stream, compares 49,656 cases in 1,771 CPU-s on a loaded machine. A bound other than the gate's, --part or --case skips the baseline and exits 1 on any failure.
  • --case='<identity or part of one>' prints the full diff and stack of each failure whose identity contains it, such as --case='"# a<br/>"'.
  • --jobs=<n> sets the number of worker processes, the machine's cores up to 16 by default. Findings do not depend on it.
  • Exit 0 is a pass and exit 1 a finding. Exit 2 is a bad flag, a crashed worker or a checkpoint the script cannot read. At the gate the run also fails when it compares other than 9,500 cases, the GATE_CASES constant, so a change that makes parse refuse more sources cannot shrink the gate while the baseline still matches. Update the constant only with the reason the count moved.
  • After a fix removes a known finding, the gate fails until you run pnpm check:markdown-round-trip --lower. The baseline never grows past the base commit's copy. --init seeds only a rule the baseline lacks, and --lower and --init write nothing when the case count misses GATE_CASES.

Read a failure

A finding prints its rule, identity and the first line of its detail:

markdown-round-trip "# a<br/>"
  serialized "a\n\n"; the parse again differs at .children.0: {"type":"paragraph","children":[{"text":"a"}]}, not {"level":1,"type":"heading","children":[{"text":"a\n"}]}

markdown-stream "1. a\na\n\n1. a"
  chunked at 1,9,10, the continued result differs at .slice.content.1.listId: "b", not "a"

The first is a known finding, a heading that ends in a break and serializes as a paragraph. The second is what the gate prints when findMarkdownSegmentBoundaries stops keeping a list open, as What a pass proves says. The preview of the first 9 characters ends in 1, which is not a list marker yet. So the segment scanner commits a boundary after the lazy line, and the continuation splits one list in two. Run --case on the identity for the full documents.

Tell a real bug from a modeling error before you change code:

  • Real bug. A user can write the source, or a stream can deliver that chunking, and the codec breaks a rule. Fix the codec, then run --lower.
  • Modeling error. The script compares something the codec does not promise, such as a list id. Fix the script with the reason.
  • Intended change. A change to what the codec keeps, such as a new syntax it refuses, changes the rule too. Change the rule and docs/vision/plate.md together. Never weaken a rule or add a baseline entry to get green.

Findings

The gate finds 157: 156 markdown-round-trip and 1 markdown-throws. Read by their examples, the round-trip entries fall into these classes. No log proves that each class has a single cause:

  • An emoji shortcode inside a div. <div>:+1:</div> comes back with the emoji glyph and escaped tags.
  • A callout whose text starts or ends with a break. <callout>\na<br/>\n</callout> loses its break.
  • A heading that ends in a break. # a<br/> serializes as a paragraph, and so does a setext heading over a<br/>.
  • A heading that starts with a break. # <br/>a serializes as a setext heading and loses the break.
  • An autolink before a shortcode. https://a.b:+1: gives a link whose URL holds the glyph.
  • A break before an autolink. <br/>https://a.b serializes the break as a backslash and a space.
  • An unclosed <u>. The serializer closes it around the text after it, and <br/><u> loses its break.
  • A task item that starts with a break. - [ ] <br/> loses its checkbox.
  • Two breaks in a row. - <br/><br/> gains a third newline.

The other entry has one cause:

  • A footnote definition in a quote. > [^1]:+1: throws Markdown node mapping "blockquote" expected block content. during serialize. 1 markdown-throws entry.

--inline=3 --part=round-trip fails 4,894 round trips and 24 throws, and every throw is a footnote definition in a quote. --blocks=3 fails 232 round trips and 1 throw, and no streamed source fails. Neither run finds a markdown-line-endings case. The deep runs read no baseline.

What a pass proves

Bugs planted on this tree showed where the gate holds:

  • Reverting e1fb01295e's codec changes, which kept links, trailing breaks and table images, gives 330 markdown-round-trip and 74 markdown-throws findings, 199 of them new. - a<br/> drops its break, and a heading with an autolink throws heading expected inline content.
  • Dropping the !state.openList test in markdownSegments.ts gives 4 markdown-stream and 4 markdown-stream-final findings, such as 1. a\n\n1. a chunked at 1,7,8 and 1. a\na\n\n1. a chunked at 1,9,10, two lists where one belongs.
  • Running the codec of 0cfbdc701a, before the line-ending pass in markdownConversion.ts and the bare URL check in markdownIntrinsics.ts, gives 405 markdown-line-endings findings, such as - [ ] \n_, whose CRLF form keeps a leading line ending. It also fails the five bare URL sources that the script held then. https://a.b.\na serializes to https://a.b.\\\na, which parses again without the link. <https://a.b'> a serializes to https://a.b' a, which parses again with the ' cut from the URL.
  • Dropping remarkSeparateEscapes from the parse in markdownConversion.ts gives 7 markdown-round-trip findings: the ], www. and email sources and four div sources, such as <div>https://a.b </div>. <https://a.b]> a serializes to https\://a.b] a, which parses again with a link to https://a.b, because mdast-util-gfm-autolink-literal finds the URL in the decoded text.
  • Dropping the MARKDOWN_DEFINITION.test(source) fallback in markdownConversion.ts gives stream findings on [a]\n\n[a]: b chunked at 1,11 and [^1]: a\n\n[^1]: a chunked at 1,13, a reference that a later definition does not resolve.
  • Keeping a segment that ends in an inline node in markdownConversion.ts gives a stream finding on <div>a</div>\n\n<div>a</div> chunked at 1,18, two text runs that normalization would join.
  • Keeping a segment that ends inside an unclosed tag in markdownConversion.ts gives 40 markdown-stream and 40 markdown-stream-final findings, such as </callout> <callout>\n\n--- chunked at 1,24, a body that falls out of its tag.

Each run used the gate bound and restored the product files byte for byte. The remarkSeparateEscapes run used this sync's script. The openList and 0cfbdc701a runs used the script of fix(markdown): read CR line endings as LF and keep a bare URL a link, and the others ran at the earlier sync, test(platejs): check Markdown round trips and streamed parses on every small document.

Coverage record

Synced at fix(markdown): keep a streamed tag open until its real closer. The verification describes these files as they stand there:

FileWhat the check exercises
packages/platejs/src/markdown/lib/MarkdownPlugin.tsparse, parseSlice and serialize
packages/platejs/src/markdown/lib/internal/markdownConversion.tsthe full parse, the line-ending pass, checkpoints and segment reuse
packages/platejs/src/markdown/lib/internal/markdownSegments.tswhere a streamed parse may commit a segment
packages/platejs/src/markdown/lib/internal/the mappings, tags, lines and intrinsics both directions use
packages/platejs/src/markdown/lib/deserializer/Markdown to nodes
packages/platejs/src/markdown/lib/serializer/nodes to Markdown
packages/platejs/src/markdown/lib/__tests__/createTestEditor.tsxthe plugins under test
packages/platejs/src/markdown/lib/__tests__/nameListIds.tscomparing documents up to list ids
packages/plitejs/scripts/check-harness.tsthe flags, workers and gate the formal checks share

To see whether the verification needs another look, find the sync commit with git log --oneline -1 --grep "keep a streamed tag open", then run git log --oneline <that commit>..HEAD -- with the table's paths and packages/platejs/scripts/markdown-round-trip.ts. For each commit it lists, read the diff and confirm three things. A continuation still reads its previous result only through the checkpoint's segments and the startsWith test on its source, so merging previews by segment ends stays sound. New Markdown syntax the codec supports has an inline token or a block form. A new plugin in the test editor that changes what a source parses to is in the gate. Move the sync commit in this file forward, naming its subject, only after that check and a run of pnpm check:markdown-round-trip.

What it does not cover

  • HTML round trips and the HTML codec.
  • Streaming of most inline contents. The stream half runs only the block sequences, where each form holds a, so inline syntax streams only in the fixed bold, link and trailing-break forms.
  • Sources longer than the bound, and containers nested inside one another.
  • Plugins outside the test editor, MDX components and custom rules.
  • A serialized form that is not the source the user wrote. The check compares documents, not Markdown text.
  • A round trip of the two unbalanced tag forms. Their round trips find the known callout break class and one bug of their own: under CR-only line endings, <callout>\r a loses its indented text. That bug is in the markdown subject's Open work. A round trip of these forms joins the gate once that bug is fixed.
  • A second failure on a known source. A case records its first failure per rule, and its identity names only the source, so a known source that starts to fail in a new way, or on another chunking, still matches its baseline entry.
  • A parse that refuses a source under the default policy. Such a source counts in refused and gets no round trip.
  • The editor surface: pasting, AI streaming into a document, and the browser.

/pstack:maintain-formal-verification-skill brings this verification back in line after changes that did not recheck it.

Repository

udecode/plate

Path

.agents/skills/markdown-round-trip/SKILL.md

Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.