- description
- 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.
- argument-hint
- [check | deep | lower | case <identity> | update]
- name
- markdown-round-trip
- metadata
- {"skiller":{"source":".agents/rules/markdown-round-trip.mdc"}}
# 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:
```text
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:
| File | What the check exercises |
| --- | --- |
| `packages/platejs/src/markdown/lib/MarkdownPlugin.ts` | `parse`, `parseSlice` and `serialize` |
| `packages/platejs/src/markdown/lib/internal/markdownConversion.ts` | the full parse, the line-ending pass, checkpoints and segment reuse |
| `packages/platejs/src/markdown/lib/internal/markdownSegments.ts` | where 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.tsx` | the plugins under test |
| `packages/platejs/src/markdown/lib/__tests__/nameListIds.ts` | comparing documents up to list ids |
| `packages/plitejs/scripts/check-harness.ts` | the 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.
View on GitHub