Skip to content

Commit bfcbd04

Browse files
jbedaclaude
andcommitted
docs: add why-this-is-hard.md, the companion rationale to design.md
The block/span/cluster model, the reparse ambiguities that force the freeze-based design (worked examples from the issue #37 investigation), the dialect layer, and what authors can do about a frozen paragraph. Linked from README and design.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent ca5450e commit bfcbd04

4 files changed

Lines changed: 340 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ At release time the section is retitled to the version and the prose lead is wri
99

1010
## Unreleased
1111

12+
- New doc: [docs/why-this-is-hard.md](docs/why-this-is-hard.md) explains why safe Markdown reflow is hard and what to do when a paragraph will not reflow.
13+
1214
## v0.1.6 (2026-08-10)
1315

1416
More of your prose reflows, because the tool stopped guessing.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ The `mdreflow-check` hook writes nothing and fails if anything would change, whi
131131
- [pkg.go.dev/github.com/jbeda/mdreflow](https://pkg.go.dev/github.com/jbeda/mdreflow) is the library API reference, rendered from the doc comments.
132132
- [docs/design.md](docs/design.md) is the canonical design: goals, modes, architecture, dialect handling (GFM, MDX/Docusaurus, Hugo), guarantees, API, CLI, and milestones.
133133
Design changes land there before code.
134+
- [docs/why-this-is-hard.md](docs/why-this-is-hard.md) explains why reflowing Markdown safely is hard — the block/span/cluster model, the ambiguities that force the freeze-based design, and what authors can do when a paragraph will not reflow.
134135
- [docs/m0-spike-findings.md](docs/m0-spike-findings.md) maps how dialect constructs land in goldmark's AST and why the skip-list works the way it does.
135136

136137
## License

docs/design.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ This is a living document: design changes land here first, then in code.*
77
`mdreflow` is a Go library and CLI that reflows Markdown prose.
88
Its home mode is sentence-per-line ([semantic line breaks](https://sembr.org/)), with paragraph-per-line and hard-wrap modes sharing the same pipeline.
99
It is a *reflow* tool, not a formatter: it changes where lines break inside paragraph prose and touches nothing else.
10+
[why-this-is-hard.md](why-this-is-hard.md) is the companion rationale: the block/span/cluster model, the reparse ambiguities that force the freeze-based design here, and what authors can do about a frozen paragraph.
1011

1112
- Repo/module/package/binary: `github.com/jbeda/mdreflow` / `mdreflow`
1213
- License: Apache-2.0

docs/why-this-is-hard.md

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
# Why this is hard
2+
3+
`docs/design.md` says what mdreflow does. This document explains why the
4+
problem resists simple solutions: why a tool that "just rewraps
5+
paragraphs" needs a freeze zone, emission escapes, and a fuzzing habit.
6+
Read it before proposing to make a frozen construct reflow. It assumes
7+
you know Markdown as a user; it does not assume you know CommonMark's
8+
parsing model.
9+
10+
## The three layers: block, span, cluster
11+
12+
CommonMark parses in two strictly ordered phases.
13+
14+
**Blocks come first, from raw bytes only.** Paragraphs, list items,
15+
blockquotes, headings, code blocks, and link reference definitions are
16+
determined by line shapes: markers, indentation, and blank lines. The
17+
block pass cannot see inline constructs. It does not know what a
18+
backtick is.
19+
20+
**Spans come second, per block.** Inline constructs (code spans, links,
21+
emphasis) are parsed within each block's text after block structure is
22+
settled.
23+
24+
The dependency is one-way, and the direction is the opposite of what
25+
intuition suggests: a code span never protects anything from a block
26+
rule. A blank line "inside" backticks still ends the paragraph; the span
27+
simply never forms, and each half is left holding an unpaired backtick.
28+
A `- ` at a line start still interrupts a paragraph even if the author
29+
meant it as code.
30+
31+
**Clusters are the emergent third layer.** A cluster is a maximal run of
32+
lines containing no blank line: a stretch of blocks that all touch. It
33+
is not a CommonMark term, but it is the unit that matters for safety.
34+
Every cross-block mechanism (definition title absorption, lazy
35+
continuation, paragraph interruption, adjacency-based hazards) requires
36+
direct contact. A blank line stops all of them. Nothing in the grammar
37+
reaches across one.
38+
39+
```
40+
Intro paragraph, wraps freely. <- cluster 1: fully independent
41+
42+
[logo]: /img.png -+ cluster 2: def, paragraph,
43+
"Our logo" and prose continuing on | and list touch; edits and
44+
- first bullet | verdicts here are entangled
45+
- second bullet -+ with each other
46+
47+
Closing paragraph, also free. <- cluster 3
48+
```
49+
50+
Most real documents are almost entirely single-block clusters, because
51+
authors blank-separate everything out of habit. That is why blunt
52+
per-neighborhood freezing costs so little in practice.
53+
54+
## Reflow is a fixpoint problem
55+
56+
mdreflow edits line breaks, then the result gets parsed again: by the
57+
next mdreflow run, by the site generator, by a coworker's renderer. Two
58+
requirements follow, in priority order (design.md covers the hierarchy):
59+
60+
1. **Render preservation.** The reflowed bytes must parse to the same
61+
structure and render to the same output.
62+
2. **Idempotency.** Running mdreflow on its own output must change
63+
nothing.
64+
65+
Here is the trap. The tool that decides where breaks are safe is the
66+
*inline* parser (spans mark no-break regions). The judge of the result
67+
is the *block* pass of the next parse, which is span-blind. The two
68+
speak different languages, and reflow's edits carry bytes from one
69+
jurisdiction to the other: a join or split moves bytes to new
70+
line-start positions, and line starts are exactly where block rules
71+
fire.
72+
73+
Every hard bug in this codebase is that one mismatch in different
74+
clothes.
75+
76+
## Delimiting is semantics
77+
78+
It would be convenient to firewall blocks from each other by inserting
79+
blank lines. That fails because blank lines are not separators in
80+
Markdown; they carry meaning.
81+
82+
A blank line between list items flips the list from tight to loose:
83+
every item gets wrapped in `<p>` and every renderer shows the extra
84+
spacing. An unprefixed blank line inside a blockquote splits it into two
85+
`<blockquote>` elements. A blank line inside a paragraph makes two
86+
paragraphs.
87+
88+
So clusters exist precisely where blank lines are absent because they
89+
are semantically forbidden. Wherever a blank line is free, authors
90+
already put one, and the firewall already exists. The ambiguity lives
91+
exclusively in the places where the wall cannot be built.
92+
93+
## In-cluster interactions: the hard ambiguities
94+
95+
Concrete cases, each of which defeats an obvious rule.
96+
97+
### A block above can eat the line below
98+
99+
A link reference definition without a title absorbs a quote-led line
100+
after it as its title:
101+
102+
```
103+
[logo]: /img.png
104+
"Our logo" appears in the header and more prose follows here.
105+
```
106+
107+
If rewrapping the paragraph ever puts the quoted text in absorbable
108+
position, those bytes stop being paragraph and become invisible
109+
metadata. The paragraph's own bytes were edited legally; the neighbor
110+
ate them. Byte ownership migrates across block boundaries on reparse.
111+
112+
### A sibling's safety verdict depends on your bytes
113+
114+
mdreflow parses the document once, computes a verdict for every block
115+
from those original bytes, then emits. A verdict about one block is
116+
routinely computed by reading bytes that belong to another: deciding
117+
whether this bullet is safe to rewrap requires judging the line
118+
directly above it, which the previous bullet owns.
119+
120+
```
121+
- The error reads
122+
`runnerGroups[0]: priorityClassName is not allowed` in that case.
123+
- A tenant with direct RBAC bypasses the webhook.
124+
```
125+
126+
The second bullet contains no bracket at all. Its verdict reads the
127+
first bullet's last line, sees a `[0]:` shape there, and freezes.
128+
129+
**Worked trace: how a correct verdict goes stale.** Suppose a rule let
130+
the first bullet reflow (say, because its bracket shape is inside a
131+
code span). Simplified to short words, wrapped at width 20:
132+
133+
```
134+
- aaa bbb
135+
ccc `q[l]: u` dd ee ff gg
136+
- hh ii jj kk ll mm nn oo pp qq
137+
```
138+
139+
Run 1 computes all verdicts from these bytes. Bullet 1: eligible,
140+
rewraps. Bullet 2: the line above it contains `[l]:`, freeze. Output:
141+
142+
```
143+
- aaa bbb ccc
144+
`q[l]: u` dd ee ff
145+
gg
146+
- hh ii jj kk ll mm nn oo pp qq
147+
```
148+
149+
Run 2 executes the same rules on that output. The line above bullet 2
150+
is now `gg`. Nothing dangerous there, so bullet 2's verdict flips to
151+
safe and it rewraps:
152+
153+
```
154+
- aaa bbb ccc
155+
`q[l]: u` dd ee ff
156+
gg
157+
- hh ii jj kk ll mm
158+
nn oo pp qq
159+
```
160+
161+
Two runs, two different outputs: idempotency is broken, and no
162+
individual rule was ever wrong. Bullet 2's verdict was correct when
163+
computed and stale by the time it mattered, because bullet 1's edit
164+
moved the evidence.
165+
166+
**The dependency also points the other way.** Here the danger flows
167+
downward: a lower block's rewrap changes what an upper block means.
168+
169+
```
170+
[logo]: /img.png
171+
"Our logo" is shown
172+
in the header.
173+
```
174+
175+
Today this is a titleless definition followed by a two-line paragraph:
176+
the first paragraph line fails to parse as a title (content continues
177+
after the closing quote on the same line), so the quote stays prose.
178+
Now suppose a rewrap lands the quoted phrase alone on its first line:
179+
180+
```
181+
[logo]: /img.png
182+
"Our logo"
183+
is shown in the header.
184+
```
185+
186+
The reparse sees `"Our logo"` alone on the line after the definition's
187+
destination. That is now a valid title, so the definition absorbs it.
188+
The words "Our logo" vanish from the render, and the remaining
189+
paragraph reads "is shown in the header." The paragraph's edit was
190+
locally legal; the neighbor above changed its meaning. Whether the
191+
definition line is harmless depends on the paragraph's final layout,
192+
which is only known after the paragraph is reflowed.
193+
194+
**Put both directions together and you have a cycle.** In a cluster
195+
holding a definition-shaped line and the paragraphs around it, the
196+
upper block's meaning depends on the lower block's final bytes (title
197+
absorption) while the lower block's safety verdict depends on the
198+
upper block's final bytes (the shape on the line above). A depends on
199+
B's answer and B depends on A's. Processing blocks top-down against
200+
already-updated predecessors does not resolve this: a downward sweep
201+
fixes the staleness in the trace above but leaves every upward-looking
202+
verdict reading bytes that a later block is about to change. Sweeping
203+
repeatedly until nothing moves is fixpoint iteration, which has no
204+
termination guarantee here (escape oscillators cycle forever) and
205+
cannot repair a render corruption once a single pass mints one. A
206+
verdict is only trustworthy if the bytes it reads cannot move at all,
207+
which is what freezing provides.
208+
209+
### The block pass reads labels backtick-blind
210+
211+
CommonMark link labels may contain backticks, and definitions are
212+
extracted before code spans exist. So the inline view and the block view
213+
of the same bytes can disagree:
214+
215+
```
216+
[see the `option]: value` form for details, plus more prose here.
217+
```
218+
219+
Inline-wise, `` `option]: value` `` is a code span and the `]:` is
220+
quoted text. Block-wise, `` [see the `option] `` is a legal label
221+
followed by a colon. If a rewrap leaves that shape alone at a
222+
paragraph's start, it reparses as a real definition and the text
223+
vanishes from the render. This is why "ignore bracket shapes inside
224+
code spans" is not a safe rule, even though it sounds obviously right
225+
(issue #37 has the full postmortem, including three distinct ways the
226+
rule fails under fuzzing).
227+
228+
### A split can mint a new block type
229+
230+
Nested list markers and thematic breaks share characters. A split that
231+
lands `**` as the first line of a bullet nested in an ordered item can
232+
reparse as `* **`: a thematic break, which ends the list. Emphasis
233+
markers, list bullets, and break runs are all drawn from the same tiny
234+
alphabet, and reflow moves them to line starts where the block pass
235+
gives them block meanings.
236+
237+
### Escapes must be stable under their own reparse
238+
239+
When emission cannot avoid placing a hazardous shape at a line start, it
240+
backslash-escapes it. But the escaped spelling is itself bytes that the
241+
next pass will judge. Any guard whose verdict differs between the
242+
escaped and unescaped spelling of the same construct oscillates: pass
243+
one escapes, pass two (seeing different bytes) makes a different
244+
decision, and the output never settles. Raw HTML openers (`<?`, `<!`)
245+
have exactly this property, because `\<?` no longer parses as HTML at
246+
all, which changes the span geometry the next pass computes.
247+
248+
## Why the verdicts are blunt
249+
250+
The pattern behind all of the above: **a freeze verdict may only depend
251+
on bytes that reflow cannot move, and every predicate keyed on a shape
252+
must judge all spellings of that shape the same way.** Precise guards
253+
fail this test constantly. A guard that inspects context ("is this
254+
bracket really a definition?") reads bytes that some other block's
255+
reflow may rewrite. The historical version of the definition-zone logic
256+
grew six interlocking adjacency guards and the fuzzer kept finding a
257+
seventh shape.
258+
259+
The blunt alternative: freeze by shape, on raw bytes, over the whole
260+
dangerous neighborhood. Frozen bytes are the only bytes guaranteed to
261+
reparse identically. Decisions are made per block, but the inputs are
262+
cluster-scoped (the raw line above, def-shaped lines anywhere in the
263+
contiguous run), so the freeze's blast radius approximates the
264+
dangerous part of the cluster.
265+
266+
The cost is real but small and measurable: on a 266-file production
267+
docset, the definition zone freezes roughly ten lines, concentrated in
268+
documents that quote `label:` syntax inside code spans. The design trade
269+
is coverage of rare shapes for guaranteed render preservation of common
270+
ones.
271+
272+
## Dialects multiply the grammar
273+
274+
Everything above assumed one grammar. There are two dialects (GFM and
275+
MkDocs), and a dialect changes both layers at once.
276+
277+
**Span geometry shifts.** GFM linkify turns bare URLs into links, and a
278+
backtick inside a bare URL is destination content, not a delimiter. The
279+
same bytes have different span boundaries with linkify on or off, which
280+
means different no-break regions and different legal splits (issue #33
281+
traced a family of frozen lines to exactly this).
282+
283+
**Block triggers shift.** MkDocs admonitions make `!!! note` a marker
284+
line whose body must keep a 4-space indent. In GFM those are ordinary
285+
paragraph bytes. A line shape that is inert in one dialect is load-
286+
bearing structure in the other.
287+
288+
**Extensions redraw category lines.** With footnotes enabled, `[^x]:`
289+
is a footnote definition (a reflowable body); without them it is an
290+
ordinary link reference definition (frozen metadata). The classification
291+
of a single line flips with a parser flag.
292+
293+
The consequence for correctness work: every hazard analysis is per-
294+
dialect, and fuzzing coverage must exercise each dialect separately,
295+
because a soak that only ever parses GFM proves nothing about MkDocs
296+
inputs (issue #26 tracks this gap).
297+
298+
## What authors can do about a frozen paragraph
299+
300+
The freezes are shape-based, so authors can remove the shape. In rough
301+
order of preference:
302+
303+
- **Move Markdown-syntax-looking literals into fenced code blocks.** A
304+
quoted error message or config fragment containing `label]:`, `<?`,
305+
or bracket shapes is the most common freeze trigger. In a fenced
306+
block it is a separate, never-reflowed block and the surrounding
307+
prose becomes ordinary. For error output this is usually better doc
308+
style anyway.
309+
- **Blank-separate the neighbor, if the render change is acceptable.**
310+
A blank line detaches an adjacent block from the dangerous one and
311+
its freeze. Inside a list this makes the list loose, which is
312+
visible; between top-level blocks it is usually free.
313+
- **Hand-format the frozen paragraph once.** A freeze is byte-for-byte
314+
passthrough, not damage. A paragraph formatted by hand stays exactly
315+
as written, forever. For prose that genuinely documents definition
316+
syntax, this is the correct end state.
317+
318+
What does not work: backslash-escaping the bracket (the zone
319+
deliberately judges escaped and unescaped spellings alike, because
320+
reflow's own emission escapes must stay frozen), and escaping inside a
321+
code span (backslashes are literal there).
322+
323+
## Takeaways
324+
325+
- The safe unit of reasoning is the cluster, not the block. Any
326+
proposed fix that judges a block in isolation is wrong or lucky.
327+
- "Just look inside the code span" and "just add blank lines" are the
328+
two most tempting fixes, and both are unsound for reasons that only
329+
show up under adversarial input. The fuzzer, not code review, is the
330+
arbiter.
331+
- When a paragraph does not reflow, the first question is which freeze
332+
fired and what it protects. The answer is usually in design.md's zone
333+
section or in the fuzz seed referenced by the guard's comment.
334+
- Blunt rules with measured, small coverage cost beat precise rules
335+
with unbounded verification cost. This trade is deliberate and has
336+
survived contact with hundreds of millions of fuzz executions.

0 commit comments

Comments
 (0)