From 5ede92bf918633a681f027dbcbf76ebc0f37580a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 28 Oct 2026 14:42:09 +0100 Subject: [PATCH] =?UTF-8?q?Answer=20architecture=20round=201:=200=20?= =?UTF-8?q?=F0=9F=94=B4,=207=20=F0=9F=9F=A1=20(0=20misread);=20the=20read-?= =?UTF-8?q?back=20loop=20parts=20from=20the=20verdict,=20the=20reader=20ta?= =?UTF-8?q?kes=20the=20opening-link=20consult,=20the=20reading=20is=20abse?= =?UTF-8?q?nt=20on=20the=20parse=20path,=20a=20refused=20link=20escapes=20?= =?UTF-8?q?no=20backtick;=20root=20imports,=20highlight=20pairing=20and=20?= =?UTF-8?q?closeBracket=20filed=20as=20items=20153=20to=20155?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/decisions.md | 3 +- spec/flavour.md | 3 +- src/markdown/emit/adf-to-markdown.test.ts | 5 +++ src/markdown/emit/line-escaping.ts | 30 +++++++++---- src/markdown/inline-content.ts | 42 ++++++++++++------- src/markdown/parse/markdown-to-adf.ts | 21 ++-------- .../portable/portable-markdown-to-adf.ts | 6 ++- todo.md | 26 +++++++++++- 8 files changed, 91 insertions(+), 45 deletions(-) diff --git a/docs/decisions.md b/docs/decisions.md index 1946825..6e10c86 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -950,7 +950,8 @@ by reading the escaped line back. A writer reads each escaped line back. Where a code span opens at a text backtick, every text backtick in the line escapes, and where a link or an image opens at a text `[`, every text `[` does: `[a](b) [c]` is written `\[a](b) \[c]`. A text `[` or backtick that opens nothing stays -bare. Escaping only up to the last construct text opens read as arbitrary to the panel, and +bare. A line the reader refuses without showing what opened at text escapes every text backtick +and `[`. Escaping only up to the last construct text opens read as arbitrary to the panel, and escaping each opener in turn costs a read per opener, against goal 9. The grammar: `spec/flavour.md` §Canonical form. diff --git a/spec/flavour.md b/spec/flavour.md index 3b41650..1db91e3 100644 --- a/spec/flavour.md +++ b/spec/flavour.md @@ -57,7 +57,8 @@ normalizes to it through the round-trip. A backtick run escapes whole. The escaped line is then read back. Where it opens a code span at a text backtick, every text backtick in the line escapes: CommonMark reads no escape inside a code span, so a backtick string beside an escape can still close one. Where it opens a link or an - image at a text `[`, every text `[` in the line escapes. + image at a text `[`, every text `[` in the line escapes. A line the reader refuses without + showing what opened at text escapes every text backtick and `[`. Where a paragraph opens with what reads as a link reference definition, which resolves before any inline construct binds (a `]` inside a code span or `{attrs}` counts), an opening text `[` escapes and an opening link takes the directive link (Marks). diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index 4e80fc9..df91465 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -351,6 +351,11 @@ test('escapes every text bracket in a line where one opens link text, and every assert.equal(emitted('Use ` or ``code`` syntax'), 'Use \\` or \\`\\`code\\`\\` syntax\n') }) +test('keeps a text backtick bare in a line where a text bracket opens a link the reader refuses', () => { + const refused = paragraph({ text: 'a ` [', type: 'text' }, { attrs: { id: '1' }, type: 'mention' }, { text: '](u)', type: 'text' }) + assert.equal(markdown(adfToLosslessMarkdown(document(refused))), 'a ` \\[!adf:mention{id=1}](u)\n') +}) + test('escapes a heading closing sequence', () => { const heading = (text: string): string => markdown(adfToLosslessMarkdown(document({ attrs: { level: 2 }, content: [{ text, type: 'text' }], type: 'heading' }))) assert.equal(heading('done #'), '## done \\#\n') diff --git a/src/markdown/emit/line-escaping.ts b/src/markdown/emit/line-escaping.ts index 716b1a5..30558cc 100644 --- a/src/markdown/emit/line-escaping.ts +++ b/src/markdown/emit/line-escaping.ts @@ -35,6 +35,9 @@ type Escapings = (index: number) => InlineEscaping | undefined // Where a segment's first character landed in the output. type PlacedSegment = { segment: InlineSegment; start: number } +// The escaped line once reading it back asks for no further escape. +type ReadBack = { output: string; placed: PlacedSegment[]; reading: InlineReading } + // Which of a run's characters escape: every escapable one, or every one before `end`. type RunEscapes = { end: number; escapable: false } | { escapable: true } @@ -87,6 +90,11 @@ function resolveEmphasis(segments: readonly InlineSegment[]): InlineSegment[] { } function escape(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): AssembledLine { + const { output, placed, reading } = readBack(segments, container, claims) + return { ...guardLinkDefinition(output, placed, container), unspellableRuns: unspellableRuns(placed, reading) } +} + +function readBack(segments: readonly InlineSegment[], container: LineContainer, claims: Claims): ReadBack { const scan = segments.map((segment) => segment.text).join('') // One byte a character, never an array element or Set entry: a line may run to hundreds of millions of characters. const codes = new Uint8Array(scan.length) @@ -105,18 +113,26 @@ function escape(segments: readonly InlineSegment[], container: LineContainer, cl for (;;) { const { output, placed } = backslashed(segments, scan, withTextEscapes(scan, escapings, claimed, escapes)) const { faulted, reading } = readLineBack(output, container, claims) - const opened = faulted ? { backticks: true, brackets: true } : textOpeners(placed, output.length, reading) + const shown = textOpeners(placed, output.length, reading) + // A code span a directive slot swallowed leaves no record before the directive fault. + const opened = faulted && !shown.backticks && !shown.brackets ? { backticks: true, brackets: true } : shown const next = { backticks: escapes.backticks || opened.backticks, brackets: escapes.brackets || opened.brackets } - if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) { - const unspellable = unspellableRuns(placed, reading) - if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output, unspellableRuns: unspellable } - if (segments[0]?.nodes !== undefined) return { line: output, openingLinkAsDirective: true, unspellableRuns: [] } - return { line: `\\${output}`, unspellableRuns: unspellable } - } + if (next.backticks === escapes.backticks && next.brackets === escapes.brackets) return { output, placed, reading } escapes = next } } +// spec/flavour.md, Canonical form: a paragraph opening with what reads as a link reference definition escapes its `[`, or spells the emitter's opening link as a directive. +function guardLinkDefinition(output: string, placed: readonly PlacedSegment[], container: LineContainer): { line: string; openingLinkAsDirective?: true } { + if (container !== 'paragraph' || !opensLinkDefinition(output)) return { line: output } + return opensWithLink(placed) ? { line: output, openingLinkAsDirective: true } : { line: `\\${output}` } +} + +// Only the emitter's link opener is a `[` segment spelling nodes. +function opensWithLink([first]: readonly PlacedSegment[]): boolean { + return first?.segment.nodes !== undefined && first.segment.text === '[' +} + function withTextEscapes(scan: string, escapings: Escapings, claimed: Uint8Array, { backticks, brackets }: TextEscapes): Uint8Array { if (!backticks && !brackets) return claimed const escaped = claimed.slice() diff --git a/src/markdown/inline-content.ts b/src/markdown/inline-content.ts index c3f0714..7e031c8 100644 --- a/src/markdown/inline-content.ts +++ b/src/markdown/inline-content.ts @@ -69,7 +69,7 @@ type Piece = | { canClose: boolean; canOpen: boolean; character: string; kind: 'run'; length: number; offset: number } | { closes: boolean; kind: 'highlight'; offset: number; opens: boolean } -type Run = { canClose: boolean; canOpen: boolean; character: string; index: number; length: number; offset: number } +type Run = Extract & { index: number } // What the reader took where, by offset into the outermost content, so a writer can check its line reads as it meant. export type InlineReading = { @@ -81,6 +81,8 @@ export type InlineReading = { readonly links: number[] } +type ReadingEntry = { at: number; closer: number; kind: 'emphasis'; width: number } | { at: number; closer: number; kind: 'highlights' } | { at: number; kind: 'codeSpans' | 'links' } + // `container` is `undefined` inside a directive's content slot, the emitter's `bracketed`. type Scan = { // CommonMark's bracket stack, innermost last. @@ -98,7 +100,8 @@ type Scan = { path: ConvertErrorPath pending: string pieces: Piece[] - reading: InlineReading + // Only a read-back takes a reading; parsing records none. + reading: InlineReading | undefined source: string spans: NestedSpans targets: TargetMemo @@ -113,30 +116,37 @@ const hreflessLink = 'the link mark spells its href: this one spells none' const noDefinitions: LinkDefinitions = new Map() const linkInLink = 'no link wraps a link: the [content] this one marks already holds one' const unlinkable = 'the text of this link holds only nodes that take no link: write the link around text beside them' -export const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' +const spellableLink = 'link takes the directive form only where CommonMark cannot spell it: this one it can, as [text](url "title") or ' const textBreak: TextBreak = { kind: 'textBreak' } -// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer. -export function readInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { - const scan = freshScan(source, { definitions, path, reading: emptyReading(), targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans }) +// The outermost content: only here does a text break see both its neighbours. Whether an opening link CommonMark spells may take the directive form is the emitter's to answer, so the caller passes its answer in. +export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour, openingLinkTakesDirective: (nodes: readonly AdfNode[], path: ConvertErrorPath) => Result): Result { + const scan = freshScan(source, { definitions, path, reading: undefined, targets: targetMemo(source) }, { claims: flavourClaims[flavour], container, inLink: false, offset: 0, spans: noSpans }) const scanned = scanInline(scan) if (!scanned.ok) return scanned const nodes = partText(contentNodes(scanned.value.pieces), scan) if (!nodes.ok) return nodes + if (scan.openingSpellableLink) { + const takesDirective = openingLinkTakesDirective(nodes.value, path) + if (!takesDirective.ok) return takesDirective + if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path) + } const { image } = scanned.value - const { openingSpellableLink } = scan - return success(image === undefined ? { nodes: nodes.value, openingSpellableLink } : { image, nodes: nodes.value, openingSpellableLink }) + return success(image === undefined ? { nodes: nodes.value } : { image, nodes: nodes.value }) } // A line that fails to read still holds what the reader took before the fault. export function readLineBack(line: string, container: LineContainer, claims: Claims): { faulted: boolean; reading: InlineReading } { - const reading = emptyReading() + const reading: InlineReading = { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] } const scan = freshScan(line, { definitions: noDefinitions, path: [], reading, targets: targetMemo(line) }, { claims, container, inLink: false, offset: 0, spans: noSpans }) return { faulted: !scanInline(scan).ok, reading } } -function emptyReading(): InlineReading { - return { codeSpans: [], emphasis: new Map(), highlights: new Map(), links: [] } +function record({ reading }: Pick, entry: ReadingEntry): void { + if (reading === undefined) return + if (entry.kind === 'emphasis') reading.emphasis.set(entry.at, { closer: entry.closer, width: entry.width }) + else if (entry.kind === 'highlights') reading.highlights.set(entry.at, entry.closer) + else reading[entry.kind].push(entry.at) } function freshScan(source: string, shared: SharedScan, own: Pick): Scan { @@ -173,7 +183,7 @@ function readToken(scan: Scan, inline: InlineSource, index: number, token: Inlin return closeBracket(scan, index) case 'code-span': flush(scan, false) - scan.reading.codeSpans.push(scan.offset + index) + record(scan, { at: scan.offset + index, kind: 'codeSpans' }) pushNode(scan, { marks: [{ type: 'code' }], text: codeSpanText(scan.source.slice(index + token.opener, end - token.opener)), type: 'text' }) return success(end) case 'delimiter-run': @@ -420,7 +430,7 @@ function closeBracket(scan: Scan, index: number): Result { if (!open.image) scan.linkOpenersFrom = scan.brackets.length // Before `linkPieces`' own-marks guard: brackets that go literal mark nothing, so a carry inside them is no error. if (!open.image && holdsLink(inner)) return success(unopened(scan, open, index)) - scan.reading.links.push(scan.offset + open.start - 1) + record(scan, { at: scan.offset + open.start - 1, kind: 'links' }) const closed = open.image ? imagePieces(scan, inner, target.definition()) : linkPieces(scan, inner, target.definition()) if (!closed.ok) return closed scan.pieces.length = open.index @@ -512,11 +522,11 @@ function markedNodes(pieces: readonly Piece[], scan: Scan, highlights: boolean): const nodes = pieces.map(pieceNodes) const runs = delimiterRuns(pieces) const pairings = matchEmphasis(runs) - for (const { closer, closerOffset, opener, openerOffset, used } of pairings) scan.reading.emphasis.set(opener.offset + openerOffset, { closer: closer.offset + closerOffset, width: used }) + for (const { closer, closerOffset, opener, openerOffset, used } of pairings) record(scan, { at: opener.offset + openerOffset, closer: closer.offset + closerOffset, kind: 'emphasis', width: used }) writeUnpaired(nodes, runs, pairings) if (!markPairings(pieces, nodes, pairings)) return failure('unsupported-node-shape', ownMarksInMark, scan.path) const paired = highlights ? pairedHighlights(pieces, nodes) : [] - for (const { closerOffset, openerOffset } of paired) scan.reading.highlights.set(openerOffset, closerOffset) + for (const { closerOffset, openerOffset } of paired) record(scan, { at: openerOffset, closer: closerOffset, kind: 'highlights' }) markHighlights(pieces, nodes, paired) return success(nodes) } @@ -563,7 +573,7 @@ function pieceNodes(piece: Piece): Inline[] { function delimiterRuns(pieces: readonly Piece[]): Run[] { const runs: Run[] = [] for (const [index, piece] of pieces.entries()) { - if (piece.kind === 'run') runs.push({ canClose: piece.canClose, canOpen: piece.canOpen, character: piece.character, index, length: piece.length, offset: piece.offset }) + if (piece.kind === 'run') runs.push({ ...piece, index }) } return runs } diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index 4275c53..a65b62b 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -2,9 +2,8 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block, DirectiveBlock } from './blocks.ts' import type { BlockDirectiveNode } from '../directive/node-reading.ts' import type { ConvertFault } from '../../result.ts' -import type { Flavour } from '../flavour.ts' -import type { InlineContent, LinkDefinitions } from '../inline-content.ts' import type { LineContainer } from '../line-container.ts' +import type { LinkDefinitions } from '../inline-content.ts' import { carryFenceType, readCarriedBlock } from '../opaque-carry.ts' import { commonMarkSpelling, openingLinkTakesDirective, type SpellingMemo } from '../emit/spelling-questions.ts' import { documentAttribute, documentName, documentSpelling } from '../directive/block.ts' @@ -15,7 +14,7 @@ import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { notAString } from '../../not-a-string.ts' import { parseBlocks } from './blocks.ts' -import { readInlineContent, spellableLink } from '../inline-content.ts' +import { parseInlineContent } from '../inline-content.ts' import { readBlockDirectiveNode } from '../directive/node-reading.ts' import { spellStringAttribute, unsupportedNodeShape } from '../directive/syntax.ts' import { spellsEmpty } from '../directive/empty-keys.ts' @@ -247,26 +246,14 @@ function codeBlockNode(language: string, text: string, reading: Reading, path: C // spec/flavour.md, The CommonMark image: only a `paragraph` block gives a lone image the block it needs. function paragraphNode(text: string, reading: Reading, path: ConvertErrorPath): Result { - const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour) + const content = parseInlineContent(text, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective) if (!content.ok) return content const image = content.value.image return success(image === undefined ? withContent({ type: 'paragraph' }, content.value.nodes) : image) } export function contentNode(node: AdfNode, text: string, reading: Reading, path: ConvertErrorPath, container: LineContainer): Result { - const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour) + const content = parseInlineContent(text, reading.definitions, path, container, reading.flavour, openingLinkTakesDirective) if (!content.ok) return content return success(withContent(node, content.value.nodes)) } - -export function parseInlineContent(source: string, definitions: LinkDefinitions, path: ConvertErrorPath, container: LineContainer, flavour: Flavour): Result { - const read = readInlineContent(source, definitions, path, container, flavour) - if (!read.ok) return read - const { image, nodes, openingSpellableLink } = read.value - if (openingSpellableLink) { - const takesDirective = openingLinkTakesDirective(nodes, path) - if (!takesDirective.ok) return takesDirective - if (!takesDirective.value) return failure('unsupported-node-shape', spellableLink, path) - } - return success(image === undefined ? { nodes } : { image, nodes }) -} diff --git a/src/markdown/portable/portable-markdown-to-adf.ts b/src/markdown/portable/portable-markdown-to-adf.ts index ff138cf..9512784 100644 --- a/src/markdown/portable/portable-markdown-to-adf.ts +++ b/src/markdown/portable/portable-markdown-to-adf.ts @@ -2,10 +2,12 @@ import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { Block } from '../parse/blocks.ts' import type { ConvertError, ConvertErrorPath, ParseError, Result, SourcePosition } from '../../result.ts' import type { FlavourBlockReaders, FlavourReading } from '../parse/markdown-to-adf.ts' -import { containerNode, contentNode, listNode, parseInlineContent, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts' +import { containerNode, contentNode, listNode, readBlocks, readDocument, tooDeep } from '../parse/markdown-to-adf.ts' import { largestNesting } from '../../nesting.ts' import { leadingMarker, readAlertMarker, readTaskMarker } from './conventions.ts' import { mintTaskIds } from './task-ids.ts' +import { openingLinkTakesDirective } from '../emit/spelling-questions.ts' +import { parseInlineContent } from '../inline-content.ts' import { pathTo, splitTaskList } from './task-list.ts' import { positioned, success } from '../../result.ts' import { withinEngineLimits } from '../../length-bound.ts' @@ -55,7 +57,7 @@ function quoteReader(foldedType: 'expand' | 'nestedExpand'): FlavourBlockReaders const { folded, panelType } = led.marker const { line, rest } = markerLine(led.text) if (!folded) return filledNode({ attrs: { panelType }, type: 'panel' }, readBlocks([...paragraphsOf(led.position, line, rest), ...body], reading, path, depth + 1)) - const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour) + const title = parseInlineContent(line, reading.definitions, path, 'paragraph', reading.flavour, openingLinkTakesDirective) if (!title.ok) return title const text = titleText(title.value.nodes) return filledNode(text === '' ? { type: foldedType } : { attrs: { title: text }, type: foldedType }, readBlocks([...paragraphsOf(led.position, rest), ...body], { ...reading, blocks: expandBlockReaders }, path, depth + 1)) diff --git a/todo.md b/todo.md index 640d5d7..80688e0 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 0.1` -`Next ID = 153` +`Next ID = 156` ## Items @@ -24,8 +24,11 @@ | 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 | | 73 | 0.3.0 | 33, 91, 109, 115, 132, 133, 145, 146, 150, 152 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 | | 72 | 0.3.0 | 33, 91, 109, 115, 132, 133, 145, 146, 150, 152 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 | +| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | | 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 | | 75 | 0.4.0 | | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 1 | 1 | 1 | 1 | 2 | code health by 2 | 17.4 | +| 154 | 0.4.0 | | | **Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 | +| 155 | 0.4.0 | | | **Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller.** | 2 | 1 | 2 | 3 | 4 | code health by 4 | 17.4 | | 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 68 | 0.4.0 | | defect | **Carry a non-text inline node holding `content` or `text`, a block holding `text`, and a leaf block holding `content`.** | 2 | 1 | 2 | 3 | 3 | 1 by 2 | 13.1 | | 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 | @@ -186,6 +189,14 @@ Gitea copy holds no issues or releases to move. Actions stay off on the mirror: otherwise run there. When this lands, the `docs/decisions.md` entry "GitHub is canonical" loses its interim sentence. +### 153. Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format. + +The writers read each escaped line back through `src/markdown/inline-content.ts`'s scan. A writer +question put back on that scan's path, such as the opening-link consult it takes as an argument +today, would close the cycle writer → reader → writer and recurse without bound on a paragraph +opening with a spellable `!adf:link` (§Nothing recurses unbounded). No `no-restricted-imports` +override covers `src/markdown/*.ts`; adding one, with its `ci.sh` probe, extends the decision. + ### 128. Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks. Mutation testing left about 40 mutants alive in code moved verbatim into `src/markdown/portable/` @@ -202,6 +213,19 @@ type", that is an ordinary code language, so the property reaches `readCarriedBl where a random edit lands in a canonical document. Write `adf:` and `adf:` fences, canonical and hostile. Technical principle: for pure logic over many inputs, prefer a property test, so the property must reach the fenced carry's refusals itself. Found by the technical-principles audit, 2026-10-04. +### 154. Pair highlights over the inline reader's pieces by their offsets before building nodes, so no placeholder text node stands in for a delimiter. + +A comprehension seat found `src/markdown/inline-content.ts` `resolveNodes` / `pairedHighlights` +hard: an empty text node stands in for each `==` until pairing, then a cursor walk overwrites it. +The pieces now carry their offsets, so the pairing can run on them first. Make that place easier to +read. + +### 155. Have `closeBracket` return the link, image or literal bracket it builds, and apply it to the scan in one caller. + +A comprehension seat found `src/markdown/inline-content.ts` `closeBracket` / `truncatePieces` +hard: `closeBracket`, `unopened`, `literalClose` and `popBracket` each write `scan.pieces`, the +link openers or the reading. Make that place easier to read. + ### 45. Replace `isAdfDocument` with a reader returning `Result`. Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the