Answer the stability nits: the runs a trailing-anchored regex re-walks
CI / gate (push) Successful in 8s

This commit is contained in:
2026-09-01 20:11:41 +02:00
parent e68dbc41a3
commit 5a41547222
4 changed files with 45 additions and 34 deletions
+2 -1
View File
@@ -6,7 +6,8 @@ One directory per contract kind, each landing with its milestone:
document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped document, byte for byte, and that `markdownToAdf` must read back to it (AGENTS.md §2). Grouped
by what the fixture exercises. by what the fixture exercises.
- `normalization/``<name>.md` + `<name>.json`: markdown input, and the document - `normalization/``<name>.md` + `<name>.json`: markdown input, and the document
`markdownToAdf` must build from it. One-way; the markdown is not canonical. `markdownToAdf` must build from it, which must in turn emit and read back to itself. The
markdown is not canonical.
- `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it - `errors/``<name>.md`: markdown input that must not convert. A `<name>.error` beside it
pins which error. pins which error.
- `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No - `real-payloads/``<name>.json`: sanitized live ADF, round-tripped ADF→markdown→ADF. No
+14 -2
View File
@@ -223,6 +223,18 @@ export function setextHeadingLevel(line: string): number | undefined {
return underline.startsWith('=') ? 1 : 2 return underline.startsWith('=') ? 1 : 2
} }
export function trimSpace(text: string): string { function spaceOrTab(character: string): boolean {
return text.replace(/^[ \t]+|[ \t]+$/g, '') return character === ' ' || character === '\t'
}
export function trimSpace(text: string): string {
let start = 0
while (start < text.length && spaceOrTab(text.charAt(start))) start += 1
return trimTrailingSpace(text.slice(start))
}
export function trimTrailingSpace(text: string): string {
let end = text.length
while (end > 0 && spaceOrTab(text.charAt(end - 1))) end -= 1
return text.slice(0, end)
} }
+3 -5
View File
@@ -1,7 +1,7 @@
import type { AdfMark, AdfNode } from '../../adf/document.ts' import type { AdfMark, AdfNode } from '../../adf/document.ts'
import type { EmphasisPairing } from '../emphasis-matching.ts' import type { EmphasisPairing } from '../emphasis-matching.ts'
import type { LinkDefinition } from '../link-syntax.ts' import type { LinkDefinition } from '../link-syntax.ts'
import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink } from '../commonmark-grammar.ts' import { backslashEscape, decodeTextEscapes, inlineHtmlConstruct, readBracketedAutolink, readEmailAutolink, trimTrailingSpace } from '../commonmark-grammar.ts'
import { backtickRun, closingBacktickRun } from '../backtick-runs.ts' import { backtickRun, closingBacktickRun } from '../backtick-runs.ts'
import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts' import { delimiterFlags, matchEmphasis, runLength } from '../emphasis-matching.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -28,9 +28,7 @@ type Run = { canClose: boolean; canOpen: boolean; character: string; index: numb
type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string } type Scan = { definitions: LinkDefinitions; path: ConvertErrorPath; pending: string; pieces: Piece[]; source: string }
const hardBreakSpaces = / {2,}$/
const imageAlone = 'an image fits only as a paragraph of its own' const imageAlone = 'an image fits only as a paragraph of its own'
const trailingSpace = /[ \t]+$/
export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> { export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath): Result<InlineContent> {
const scan: Scan = { definitions, path, pending: '', pieces: [], source } const scan: Scan = { definitions, path, pending: '', pieces: [], source }
@@ -95,7 +93,7 @@ function readBackslash(scan: Scan, index: number): number {
} }
function readLineEnding(scan: Scan, index: number): number { function readLineEnding(scan: Scan, index: number): number {
const hard = hardBreakSpaces.test(scan.pending) const hard = scan.pending.endsWith(' ')
flush(scan, true) flush(scan, true)
if (hard) pushNode(scan, { type: 'hardBreak' }) if (hard) pushNode(scan, { type: 'hardBreak' })
else scan.pending = ' ' else scan.pending = ' '
@@ -154,7 +152,7 @@ function readDirective(scan: Scan, index: number): Result<number> {
} }
function flush(scan: Scan, strip: boolean): void { function flush(scan: Scan, strip: boolean): void {
const raw = strip ? scan.pending.replace(trailingSpace, '') : scan.pending const raw = strip ? trimTrailingSpace(scan.pending) : scan.pending
scan.pending = '' scan.pending = ''
if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] }) if (raw !== '') scan.pieces.push({ kind: 'nodes', nodes: [{ text: decodeTextEscapes(raw), type: 'text' }] })
} }
+26 -26
View File
@@ -281,32 +281,7 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
`spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost `spec/flavour.md`'s closing-fence sentence now says: a run reaching past the innermost
leaves the fence it did not close a named error, which §2 prefers to closing more than the leaves the fence it did not close a named error, which §2 prefers to closing more than the
author wrote. author wrote.
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the - [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
`language` attribute a bare fence leaves it; the media family's composition; and both
table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both
directions answer alike and moves to the `markdown/` root with the language attribute.
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
in a container body with no blank line between them. That reduces the three cases to one
rule, separation only where its absence would merge the blocks: the `:::` fence is
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
accepted and emit refused, not the last. Two adjacent lists of a kind are what
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
whose blank line pops the container the list's identity hung from. The parser opens no list
beside one of its own kind instead, the way it already drops the blank lines between items;
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
`normalization/` arm emits each document and reads it back from here, so the population that
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
narrow.
- [x] **3g — The node tables read backwards.** `commonmark-subset/` reads back, the first
directory to. A parsed directive becomes its node: the name to the type and an unknown one directory to. A parsed directive becomes its node: the name to the type and an unknown one
to a named error, the arg to the attribute it names, each value to the type its section to a named error, the arg to the attribute it names, each value to the type its section
assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift assigns, the body to `content`, the reserved `marks` key to the marks array. 3a's drift
@@ -334,3 +309,28 @@ Under **3 — `markdownToAdf` (`0.1.0`)**:
path and returns `Result`, so no second reader took it. The drift guard earned itself on path and returns `Result`, so no second reader took it. The drift guard earned itself on
the way in: the spec's `text` attribute was missing from three inline table entries, which the way in: the spec's `text` attribute was missing from three inline table entries, which
the content slot spells and the vocabulary walk already passes over. the content slot spells and the vocabulary walk already passes over.
- [x] **3h — The block nodes.** `block-nodes/` reads back: the `codeBlock` directive's fenced body and the
`language` attribute a bare fence leaves it; the media family's composition; and both
table forms, the pipe table's cell split and its named errors. `fenceInfo` is a rule both
directions answer alike and moves to the `markdown/` root with the language attribute.
**Settled** (the maintainer, 2026-08-27): 1d's last pick, the one
`container-block-separation` holds — a CommonMark block and a directive block sit adjacent
in a container body with no blank line between them. That reduces the three cases to one
rule, separation only where its absence would merge the blocks: the `:::` fence is
separation already, and 3c's claim ends the lazy continuation that would otherwise swallow
it. The fixture becomes a round-trip pair, and with `nested-list-separation` and 3e's pair
that empties `corpus/unspellable/`: this chunk settles the directory's own guard in
`corpus.test.ts` too, and `unspelled-block-separation`, which loses its only cause here.
The emitter's other refusals survive on causes no fixture in that directory covers, so
3k's one-list pass is where they get fixtures or the directory goes.
**Settled** (the maintainer, 2026-09-01): losing that cause closed one of the shapes input
accepted and emit refused, not the last. Two adjacent lists of a kind are what
`adfToMarkdown` refuses and one `- ` spelling cannot hold apart, and the walk reached them
two ways — a marker change, which CommonMark opens a second list on, and an empty last item,
whose blank line pops the container the list's identity hung from. The parser opens no list
beside one of its own kind instead, the way it already drops the blank lines between items;
3k owes the CommonMark suite an exception where the reference HTML holds two `<ul>`. The
`normalization/` arm emits each document and reads it back from here, so the population that
class lives in is checked rather than read. The README's canonical-fixpoint sentence still
claims more than the parser keeps — 3e's three shapes — which stays milestone 5's to
narrow.