You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Does the editor converge on a stable serialization for the corpus subtrees whose syntax Leafdown supports, and is that property worth asserting automatically?
Convergence means serialize(parse(source)) and a second pass over that result produce the same string. The editor is allowed to normalize on first open; it must not keep changing the document afterwards.
Context
src/AGENTS.md requires preserving Markdown round-trip behavior when schemas, parsing, serialization, clipboard handling, or document transformations change. Today that requirement is enforced by about 30 hand-written cases in src/features/editor/tests/markdownCompatibility.test.tsx.
The corpus/ directory holds 70 committed Markdown files maintained against CommonMark 0.31.2 and the GFM specification, but nothing reads it. CONTRIBUTING.md directs contributors to open it manually. The source-projection and serialization cluster is about 4,800 source lines and is the most intricate part of the codebase, so a silent round-trip regression there is currently caught only when a human opens the app.
Prior investigation: analysis from an earlier session, recorded below so it does not need re-deriving.
Validate
Prototype the test and count failures. The design and its supporting measurements:
Assert convergence, not byte identity. Nine of the fifteen candidate files contain a construct the serializer normalizes: - and + bullets, non-*** thematic breaks, underscore emphasis, indented code, reference definitions, raw HTML blocks, and trailing-two-space hard breaks. A strict-identity assertion would therefore need a per-file expected output, which is a generated baseline in all but name and would be rubber-stamped on review. Convergence needs no baseline and no deviation list.
Scope to supported syntax:commonmark/ (7 files), gfm/ (5), and isolated/end-of-file/ (3). The last three are the highest-value fixtures in the corpus, covering an unclosed code fence, an unclosed HTML comment, and an unclosed directive at end of file.
Cost is measured, not estimated.markdownCompatibility.test.tsx runs about 24 editor mounts in 1.17s of tests phase, roughly 50ms per mount. Fifteen files at two mounts each is about 1.5s, plus the roughly 4.8s editor import any editor test file already pays.
Existing infrastructure covers it:setupMilkdownEditorMount, getMarkdown, createMarkdownReferenceContext, and mockTauriApiCommand. Reading corpus files from disk is precedented by tests/editorPresentation.test.tsx, which reads MilkdownEditor.css. .gitattributes normalizes every file outside corpus/boundaries/bytes/** to LF, so the loader needs no line-ending handling.
Two of the fifteen files reference images and need mockTauriApiCommand("resolveMarkdownImageTarget", ...), or the resolver produces noise instead of a clean failure.
Interpreting the result:
No failures: ship it as a permanent regression guard.
A few failures: each is a finding, either a serializer bug worth its own issue or a deviation that earns a documented exception.
Most failures: the editor does not converge, which is a larger finding than this test, and the test waits behind fixing that.
Exit criteria
The question is answered.
The outcome below records the conclusion, tradeoffs, and supporting evidence links.
Follow-up issues are created only for selected work.
Owning documentation updates are identified when the outcome changes durable direction.
Outcome
Run against main at 07f5893, 16 files, two mounts each: 13 converge, 3 do not.
That lands in the "a few failures" band. The three failures are one serializer bug,
not three, and the test is worth keeping once that bug is fixed.
The failure
commonmark/lists-and-blockquotes.md, commonmark/text-and-breaks.md, and gfm/tables.md fail with the same shape. When a list item's first child is a
non-paragraph block, the serializer has no text for the marker line and emits a
bare marker, a blank line, and then the indented block:
- ``` serializes to *
code
``` ```
code
```
Per CommonMark 0.31.2 an item may begin with at most one blank line, so that output
reparses as an empty list item plus a detached top-level block. The second pass
then serializes the block at column zero and the nesting is gone.
Confirmed against micromark 4.0.2 as an independent reference:
*\n\n + fence → <ul><li></li></ul> followed by a sibling <pre>
*\n + fence (no blank line) → <ul><li><pre> — the block stays in the item
* + fence on the marker line → the block stays in the item
So the parser is correct and the serializer is at fault. Dropping the blank line
after a bare marker fixes it; putting the first child on the marker line also fixes it.
The bug is wider than the corpus shows
The corpus exercises three cases. A minimal sweep shows every non-paragraph first
child loses its nesting on the second open, ordered and unordered alike:
First child of the list item
Converges
Paragraph
yes
Fenced code
no
Indented code
no
Table
no
Blockquote
no
Nested list
no
Heading
no
Thematic break
no
A non-paragraph block in any later position converges — the trigger is specifically
the first child. 1. + fence fails identically, so it is not marker-type specific.
This is reachable data loss, not a test artifact: the save path writes getMilkdownEditorMarkdown output verbatim (src/features/editor/hooks/useMilkdownEditorInstance.ts:109),
so one open-edit-save destroys the nesting on disk and the loss surfaces on the next
open. Nothing in markdownCompatibility.test.tsx covers a list item with a
non-paragraph first child, which is why 4,800 lines of projection and serialization
carried this uncaught.
Tradeoffs
Convergence over byte identity works, but only as an interim. See the measured
identity gap below. The design absorbed the normalization it was chosen to absorb
without a single baseline file, but "no baseline needed" turned out to be a weaker
justification than the analysis assumed, because identity is not merely
inconvenient to assert today — it is far out of reach.
Convergence is a weak assertion by construction. It caught a structural loss
here, but it cannot catch a normalization that is stable and wrong. **one / ** collapsing to escaped literal text converges cleanly, as predicted in the
comment above. A green run means "stops changing", never "changes correctly".
Cost matched the estimate. 7.82s wall for the file: 1.59s tests phase, 3.92s
import, the rest transform and environment. Two mounts of sixteen files cost about
what twenty-four mounts cost in markdownCompatibility.test.tsx.
No new infrastructure.setupMilkdownEditorMount, createMarkdownReferenceContext,
and mockTauriApiCommand covered it; disk reads follow editorPresentation.test.tsx.
The whole prototype is 48 lines.
The identity gap, measured
Added after the fact, prompted by the fair question of why the editor normalizes at
all rather than saving the corpus unchanged. The analysis above claimed nine of
fifteen files "contain a construct the serializer normalizes". Measured on the
scoped set: 12 of 16 differ from source on first open, several by around 100
lines. Three distinct causes, and the listed constructs are not the main one:
Escaping applied out of context — dominant by volume. The serializer escapes
characters that would not parse as syntax where they sit: garden_sensor_name → garden\_sensor\_name (intraword _ is never emphasis in CommonMark), *opening-only → \*opening-only (an unpaired * is already literal), name@example → name\@example, www.example_.com → www\.example\_.com.
Not forced by the document model. This is How a Markdown escape should be represented in the editor #245's subject.
Structural spelling the model does not retain — lazy blockquote continuation
gains an explicit >; tight lists are written loose.
The 4 identical files (gfm/tagfilter.md and the three isolated/end-of-file/
fixtures) are identical because they hold almost no inline constructs to escape, not
because anything preserves authored form. So there is no identity-holding subset
worth pinning, and no useful hybrid assertion between identity and convergence.
This does not invalidate the test — convergence still caught #247 — but it does
change its standing. Byte identity is the property actually worth wanting, the
project is already moving toward it (#243), and convergence is the interim that fits
while #245 is open. A green run must not be read as "the corpus saves unchanged".
Inspected samples all preserved meaning while changing text; that was not checked
across all 12 files and is not claimed.
Corrections to the analysis above
isolated/end-of-file/ holds 4 files, not 3. unclosed-html-block.md was
missing from the count; it converges. Scoped total is 16, not 15.
One scoped file references images, not two — only commonmark/links-and-images.md.
The resolveMarkdownImageTarget mock is still required, for that one file.
Nine of fifteen normalizing understates the gap. It is 12 of 16, and the
enumerated constructs are not the dominant driver.
Rejected along the way
Per-file expected output. Rejected before the run for the reason recorded above,
and the run confirmed it: the three failures are one bug, and a baseline would have
frozen the broken output of all three as expected instead of surfacing the cause.
Landing the test with the three files skipped. A permanent guard shipping with
three skips is rot at birth, and the skips would outlive the memory of why.
Widening the spike to practical/ or extensions/ once failures appeared.
Out of scope above, and the failures were already one root cause; more files would
have added fixture wrangling, not evidence.
Recommended follow-up
Fix the serializer first — filed as A list item's non-paragraph first child loses its nesting on save #247. A list item whose first child is a
non-paragraph block loses its nesting on save. Reachable data loss, wider than the
three corpus files, and the table above is the acceptance surface.
Then land the convergence test green over all 16 files, not before. It is the
regression guard that proves the fix and keeps it fixed. Landed in test: assert corpus round-trip convergence for supported syntax #249, framed
there as an interim assertion rather than a settled one.
CONTRIBUTING.md:134 points contributors at corpus/ for manual testing. Once
the test lands, note that commonmark/, gfm/, and isolated/end-of-file/ carry
automated convergence coverage and the manual pass is for rendering, interaction,
and navigator behavior. The out-of-scope subtrees stay fully manual.
Residual uncertainty
The fix shape is not decided. Dropping the blank line and hoisting the first
child onto the marker line both satisfy CommonMark; which one the Milkdown
serializer can express cleanly was not investigated. That is the fix's design work,
deliberately not started here.
Convergence beyond pass two is assumed, not proven. The prototype asserts
pass 1 equals pass 2. The minimal sweep ran a third pass and every case was stable
by then, but the shipped test only checks the first two.
Third-pass stability of the failing cases is a red herring worth naming. All
three settle by pass three — into the wrong document. Stability is not correctness.
Tight lists are serialized loose — settled after the fact and filed as Tight bullet lists are saved as loose lists #250. - A\n- B becomes * A\n\n* B, which changes rendered output (<li>A</li> versus <li><p>A</p></li>). The schema does carry tightness; it is lost on serialization,
where bullet_list and list_item forward a string attribute into a boolean field
and "false" is truthy. Upstream defect in @milkdown/preset-commonmark@7.21.3,
fixable here by local schema override. Cause 3 in the identity gap above is therefore
partly a defect rather than a model limitation.
Prototype
Deleted after the run rather than committed, since implementation was out of scope
for this spike. Reproduce with src/features/editor/tests/corpusRoundTrip.spike.test.tsx:
import{readFileSync}from"node:fs";import{resolve}from"node:path";import{beforeEach,describe,expect,it}from"vitest";import{createMarkdownReferenceContext}from"@/test/factories/editor";import{setupMilkdownEditorMount}from"@/test/utils/milkdown";import{mockTauriApiCommand}from"@/test/utils/tauriApi";constmountEditor=setupMilkdownEditorMount(createMarkdownReferenceContext());constcorpusFiles=["commonmark/blocks.md","commonmark/code.md","commonmark/emphasis.md","commonmark/html.md","commonmark/links-and-images.md","commonmark/lists-and-blockquotes.md","commonmark/text-and-breaks.md","gfm/autolinks.md","gfm/strikethrough.md","gfm/tables.md","gfm/tagfilter.md","gfm/task-lists.md","isolated/end-of-file/incomplete-html-comment.md","isolated/end-of-file/unclosed-code-fence.md","isolated/end-of-file/unclosed-directive.md","isolated/end-of-file/unclosed-html-block.md",];constreadCorpusFile=(relativePath: string)=>readFileSync(resolve(process.cwd(),"corpus",relativePath),"utf8");describe("corpus round trip",()=>{beforeEach(()=>{mockTauriApiCommand("resolveMarkdownImageTarget",({ target })=>({kind: "renderable",path: `C:/Notes/${target}`,}));});it.each(corpusFiles)("converges on a stable serialization for %s",async(relativePath)=>{constsource=readCorpusFile(relativePath);constfirst=(awaitmountEditor(source)).getMarkdown();constsecond=(awaitmountEditor(first)).getMarkdown();expect(second).toBe(first);});});
Notes
Out of scope
corpus/boundaries/bytes/ (13 files). These exist to preserve CR, CRLF, BOM, NUL, and a missing final newline exactly, and reading plus serializing normalizes precisely what they are there to pin. They stay manual.
corpus/environment/ (11 files). The subject is folder shape, supported extensions, and local resource resolution, which needs the Rust backend. The frontend already covers this with the createArticleTree and createFolderContext factories.
corpus/extensions/ (18 files). Automating these mostly asserts that unsupported syntax survives as escaped literal text, a property already covered by three cases in markdownCompatibility.test.tsx.
corpus/practical/ initially. Those files carry local image and link references needing per-path resolution mocks, which turns the test into fixture wrangling.
Rendering, interaction, and navigator behavior. Convergence says nothing about them, so the manual corpus pass still happens; this only shrinks it.
Question
Does the editor converge on a stable serialization for the corpus subtrees whose syntax Leafdown supports, and is that property worth asserting automatically?
Convergence means
serialize(parse(source))and a second pass over that result produce the same string. The editor is allowed to normalize on first open; it must not keep changing the document afterwards.Context
src/AGENTS.mdrequires preserving Markdown round-trip behavior when schemas, parsing, serialization, clipboard handling, or document transformations change. Today that requirement is enforced by about 30 hand-written cases insrc/features/editor/tests/markdownCompatibility.test.tsx.The
corpus/directory holds 70 committed Markdown files maintained against CommonMark 0.31.2 and the GFM specification, but nothing reads it.CONTRIBUTING.mddirects contributors to open it manually. The source-projection and serialization cluster is about 4,800 source lines and is the most intricate part of the codebase, so a silent round-trip regression there is currently caught only when a human opens the app.Related context
Validate
Prototype the test and count failures. The design and its supporting measurements:
-and+bullets, non-***thematic breaks, underscore emphasis, indented code, reference definitions, raw HTML blocks, and trailing-two-space hard breaks. A strict-identity assertion would therefore need a per-file expected output, which is a generated baseline in all but name and would be rubber-stamped on review. Convergence needs no baseline and no deviation list.commonmark/(7 files),gfm/(5), andisolated/end-of-file/(3). The last three are the highest-value fixtures in the corpus, covering an unclosed code fence, an unclosed HTML comment, and an unclosed directive at end of file.markdownCompatibility.test.tsxruns about 24 editor mounts in 1.17s of tests phase, roughly 50ms per mount. Fifteen files at two mounts each is about 1.5s, plus the roughly 4.8s editor import any editor test file already pays.setupMilkdownEditorMount,getMarkdown,createMarkdownReferenceContext, andmockTauriApiCommand. Reading corpus files from disk is precedented bytests/editorPresentation.test.tsx, which readsMilkdownEditor.css..gitattributesnormalizes every file outsidecorpus/boundaries/bytes/**to LF, so the loader needs no line-ending handling.mockTauriApiCommand("resolveMarkdownImageTarget", ...), or the resolver produces noise instead of a clean failure.Interpreting the result:
Exit criteria
Outcome
Run against
mainat 07f5893, 16 files, two mounts each: 13 converge, 3 do not.That lands in the "a few failures" band. The three failures are one serializer bug,
not three, and the test is worth keeping once that bug is fixed.
The failure
commonmark/lists-and-blockquotes.md,commonmark/text-and-breaks.md, andgfm/tables.mdfail with the same shape. When a list item's first child is anon-paragraph block, the serializer has no text for the marker line and emits a
bare marker, a blank line, and then the indented block:
Per CommonMark 0.31.2 an item may begin with at most one blank line, so that output
reparses as an empty list item plus a detached top-level block. The second pass
then serializes the block at column zero and the nesting is gone.
Confirmed against micromark 4.0.2 as an independent reference:
*\n\n+ fence →<ul><li></li></ul>followed by a sibling<pre>*\n+ fence (no blank line) →<ul><li><pre>— the block stays in the item*+ fence on the marker line → the block stays in the itemSo the parser is correct and the serializer is at fault. Dropping the blank line
after a bare marker fixes it; putting the first child on the marker line also fixes it.
The bug is wider than the corpus shows
The corpus exercises three cases. A minimal sweep shows every non-paragraph first
child loses its nesting on the second open, ordered and unordered alike:
A non-paragraph block in any later position converges — the trigger is specifically
the first child.
1.+ fence fails identically, so it is not marker-type specific.This is reachable data loss, not a test artifact: the save path writes
getMilkdownEditorMarkdownoutput verbatim (src/features/editor/hooks/useMilkdownEditorInstance.ts:109),so one open-edit-save destroys the nesting on disk and the loss surfaces on the next
open. Nothing in
markdownCompatibility.test.tsxcovers a list item with anon-paragraph first child, which is why 4,800 lines of projection and serialization
carried this uncaught.
Tradeoffs
identity gap below. The design absorbed the normalization it was chosen to absorb
without a single baseline file, but "no baseline needed" turned out to be a weaker
justification than the analysis assumed, because identity is not merely
inconvenient to assert today — it is far out of reach.
here, but it cannot catch a normalization that is stable and wrong.
**one/**collapsing to escaped literal text converges cleanly, as predicted in thecomment above. A green run means "stops changing", never "changes correctly".
import, the rest transform and environment. Two mounts of sixteen files cost about
what twenty-four mounts cost in
markdownCompatibility.test.tsx.setupMilkdownEditorMount,createMarkdownReferenceContext,and
mockTauriApiCommandcovered it; disk reads followeditorPresentation.test.tsx.The whole prototype is 48 lines.
The identity gap, measured
Added after the fact, prompted by the fair question of why the editor normalizes at
all rather than saving the corpus unchanged. The analysis above claimed nine of
fifteen files "contain a construct the serializer normalizes". Measured on the
scoped set: 12 of 16 differ from source on first open, several by around 100
lines. Three distinct causes, and the listed constructs are not the main one:
characters that would not parse as syntax where they sit:
garden_sensor_name→garden\_sensor\_name(intraword_is never emphasis in CommonMark),*opening-only→\*opening-only(an unpaired*is already literal),name@example→name\@example,www.example_.com→www\.example\_.com.Not forced by the document model. This is How a Markdown escape should be represented in the editor #245's subject.
-bullets →*,~strike~→~~strike~~,_em_→*em*. Fixable per construct, as feat: preserve the authored autolink form on save #243 did for autolinks.gains an explicit
>; tight lists are written loose.The 4 identical files (
gfm/tagfilter.mdand the threeisolated/end-of-file/fixtures) are identical because they hold almost no inline constructs to escape, not
because anything preserves authored form. So there is no identity-holding subset
worth pinning, and no useful hybrid assertion between identity and convergence.
This does not invalidate the test — convergence still caught #247 — but it does
change its standing. Byte identity is the property actually worth wanting, the
project is already moving toward it (#243), and convergence is the interim that fits
while #245 is open. A green run must not be read as "the corpus saves unchanged".
Inspected samples all preserved meaning while changing text; that was not checked
across all 12 files and is not claimed.
Corrections to the analysis above
isolated/end-of-file/holds 4 files, not 3.unclosed-html-block.mdwasmissing from the count; it converges. Scoped total is 16, not 15.
commonmark/links-and-images.md.The
resolveMarkdownImageTargetmock is still required, for that one file.enumerated constructs are not the dominant driver.
Rejected along the way
and the run confirmed it: the three failures are one bug, and a baseline would have
frozen the broken output of all three as expected instead of surfacing the cause.
three skips is rot at birth, and the skips would outlive the memory of why.
practical/orextensions/once failures appeared.Out of scope above, and the failures were already one root cause; more files would
have added fixture wrangling, not evidence.
Recommended follow-up
non-paragraph block loses its nesting on save. Reachable data loss, wider than the
three corpus files, and the table above is the acceptance surface.
regression guard that proves the fix and keeps it fixed. Landed in test: assert corpus round-trip convergence for supported syntax #249, framed
there as an interim assertion rather than a settled one.
CONTRIBUTING.md:134points contributors atcorpus/for manual testing. Oncethe test lands, note that
commonmark/,gfm/, andisolated/end-of-file/carryautomated convergence coverage and the manual pass is for rendering, interaction,
and navigator behavior. The out-of-scope subtrees stay fully manual.
Residual uncertainty
child onto the marker line both satisfy CommonMark; which one the Milkdown
serializer can express cleanly was not investigated. That is the fix's design work,
deliberately not started here.
pass 1 equals pass 2. The minimal sweep ran a third pass and every case was stable
by then, but the shipped test only checks the first two.
three settle by pass three — into the wrong document. Stability is not correctness.
above stands: this is a guard for the change How a Markdown escape should be represented in the editor #245 produces, not a net for the open
editing-surface defects.
Tight lists are serialized loose— settled after the fact and filed as Tight bullet lists are saved as loose lists #250.- A\n- Bbecomes* A\n\n* B, which changes rendered output (<li>A</li>versus<li><p>A</p></li>). The schema does carry tightness; it is lost on serialization,where
bullet_listandlist_itemforward a string attribute into a boolean fieldand
"false"is truthy. Upstream defect in@milkdown/preset-commonmark@7.21.3,fixable here by local schema override. Cause 3 in the identity gap above is therefore
partly a defect rather than a model limitation.
Prototype
Deleted after the run rather than committed, since implementation was out of scope
for this spike. Reproduce with
src/features/editor/tests/corpusRoundTrip.spike.test.tsx:Notes
Out of scope
corpus/boundaries/bytes/(13 files). These exist to preserve CR, CRLF, BOM, NUL, and a missing final newline exactly, and reading plus serializing normalizes precisely what they are there to pin. They stay manual.corpus/environment/(11 files). The subject is folder shape, supported extensions, and local resource resolution, which needs the Rust backend. The frontend already covers this with thecreateArticleTreeandcreateFolderContextfactories.corpus/extensions/(18 files). Automating these mostly asserts that unsupported syntax survives as escaped literal text, a property already covered by three cases inmarkdownCompatibility.test.tsx.corpus/practical/initially. Those files carry local image and link references needing per-path resolution mocks, which turns the test into fixture wrangling.