3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 19:58:28 +01:00

Answer architecture round 6: each block is held to the bound as it is built, the reader's ask takes the engine guard, and the chunk ships under a worse comprehension ruling by the maintainer's call, with item 110 to fill the memo in one place

This commit is contained in:
2026-10-13 00:39:39 +02:00
committed by Lillem4n
parent 6f518dab93
commit 7caa8237f0
10 changed files with 120 additions and 64 deletions
+1 -1
View File
@@ -445,7 +445,7 @@ The emitter raises these, and so does a parse, which asks the emitter how to spe
| Code | Fires when | What you can do | | Code | Fires when | What you can do |
| --- | --- | --- | | --- | --- | --- |
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it | | `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document, or writes as a directive a node whose CommonMark spelling would be longer than 536,870,888 characters. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536,870,888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536,870,888 characters or the engine's limit, set the document aside: keep the ADF and pass it over, or show it read-only | | `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document, or writes as a directive a node whose CommonMark spelling would be longer than 536,870,888 characters. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536,870,888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536,870,888 characters or the engine's limit, set the document aside: keep the ADF and pass it over, or show it read-only |
## The guarantees ## The guarantees
+53
View File
@@ -627,3 +627,56 @@ Inherited architect, decided by:
- Locality, Shape: `src/markdown/emit/block-directive-spelling.ts:27` `spellBlockDirectiveOpener`: the opener's layout is known here and in `spellDirectiveOpener` - Locality, Shape: `src/markdown/emit/block-directive-spelling.ts:27` `spellBlockDirectiveOpener`: the opener's layout is known here and in `spellDirectiveOpener`
- Shape: `src/markdown/portable/adf-to-portable-markdown.ts:231` `reduceCodeBlock`: an ADF-to-ADF reducer carries a markdown length bound through a callback - Shape: `src/markdown/portable/adf-to-portable-markdown.ts:231` `reduceCodeBlock`: an ADF-to-ADF reducer carries a markdown length bound through a callback
- Navigation: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long refusal leads straight to one small, well-named module - Navigation: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long refusal leads straight to one small, well-named module
## 2026-10-12T19:45:45Z, PR #36 at 1f4551a, against 7a27f6b
Ruling: worse
Fell: Locality. Under the floor of 7.0: Locality 5.13, so the same ruling counts as worse.
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | worse | better | same | same |
| Inherited architect | better | worse | better | same | same |
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 6.5 | 5 | 6 | 5.5 | 5.5 |
| Mid A | 7 | 5 | 6 | 5 | 5.5 |
| Maintainability senior | 7 | 5.5 | 6.5 | 6 | 6 |
| Inherited architect | 6.5 | 5 | 5.5 | 6.5 | 5.5 |
| Mean | 6.75 | 5.13 | 6.00 | 5.75 | 5.63 |
Mid A, decided by:
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: the memo is filled here and read-and-deleted in `readableBlock`, a write-once, read-once protocol whose order is set in another module
- Locality: `src/markdown/emit/adf-to-markdown.ts:52` `writeMarkdown`: one rule, output past `longestMarkdown` is refused, is checked in six places with different triggers
- Navigation, Shape: `src/markdown/length-bound.ts:1` `tooLong`: a too-long refusal leads to one small module holding the bound, its message and the engine guard
- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: a generator replaces a boolean-returning callback, and both readers become plain loops
- Shape, Locality: `src/markdown/portable/adf-to-portable-markdown.ts:128` `portableSequence`: a refused spelling is returned where it is produced, and each block carries its own path
- Shape: `src/markdown/emit/inline-line.ts:235` `emitLeaf`: one content-or-text check, and the path is built only to refuse
Inherited architect, decided by:
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: filling and consuming the memo split across an exported predicate and a private reader, a consume-once protocol among three callers
- Locality: `src/markdown/emit/adf-to-markdown.ts:52` `writeMarkdown`: one bound enforced at five sites in four files, and a new builder has nothing nearby saying whether it needs a check
- Navigation, Shape: `src/markdown/length-bound.ts:1` `withinEngineLimits`: an output-too-long symptom leads straight to a module named for it
- Navigation: `src/markdown/parse/markdown-to-adf.ts:47` `readDocument`: the parse takes the same guard as both writers
- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: plain loops replace a boolean-returning callback
- Shape: `src/markdown/emit/inline-line.ts:235` `emitLeaf`: both leaf emitters return a plain emission, the path built only on refusal
Junior A, hardest first:
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: a mutable memo shared by three walks, deleted on read and rebased by depth
Mid A, hardest first:
- Locality: `src/markdown/emit/adf-to-markdown.ts:125` `readableBlock`: shared mutable state with no single owner, parse and emit coupled both ways
Maintainability senior, hardest first:
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: the parser and the portable reducer call into the emitter and share a reference-keyed, delete-on-read memo
Inherited architect, would restructure first:
- Locality: `src/markdown/emit/adf-to-markdown.ts:126` `readableBlock`: the memo is mutated and deleted across parse, emit and portable, and the length bound is applied at five sites
+26 -21
View File
@@ -604,17 +604,22 @@ A four-seat comprehension panel scored the project under the floor of 7. Its sco
| Mean | 6.88 | 5.63 | 6.25 | 5.63 | 6.00 | | Mean | 6.88 | 5.63 | 6.25 | 5.63 | 6.00 |
Panels on near-identical code scored overall means of 5.75 to 6.00, and a seat moves ±0.5 between Panels on near-identical code scored overall means of 5.75 to 6.00, and a seat moves ±0.5 between
runs. The maintainer shipped under a fall twice after three rounds: the move to GitHub, runs. The maintainer shipped under a fall twice after three rounds: the move to GitHub, 2026-10-04,
2026-10-04, whose chunk changed only comments in `src/`; and item 78, 2026-10-07, whose three runs whose chunk changed only comments in `src/`; and item 78, 2026-10-07, whose three runs fell below
fell below the run before it on Navigation, Locality and Shape while the overall held. Item 94, the run before it on Navigation, Locality and Shape while the overall held. Item 94, 2026-10-10,
2026-10-10, also shipped after three rounds: a definitions-only paragraph leaves no block, so ending also shipped after three rounds: a definitions-only paragraph leaves no block, so ending the list it
the list it follows takes state carried across lines, and every design read worse on Locality while follows takes state carried across lines, and every design read worse on Locality while the overall
the overall held; the scores at 6182ba6 were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean held; the scores at 6182ba6 were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean 5.63. Item
5.63. Item 56, still open, is to give that state one owner. Items 85 and 86, 2026-10-11, shipped 56, still open, is to give that state one owner. Items 85 and 86, 2026-10-11, shipped after three
after three rounds under a fall the seats laid on item 93's `splitPath` and `splitPlace`, which rounds under a fall the seats laid on item 93's `splitPath` and `splitPlace`, which place a refused
place a refused block's path by replaying `splitTaskList` on stand-in nodes; both seats read the block's path by replaying `splitTaskList` on stand-in nodes; both seats read the chunk's own image
chunk's own image builder as a gain. The scores at be23835 were Junior 6.5, Mid 6, Senior 6 and builder as a gain. The scores at be23835 were Junior 6.5, Mid 6, Senior 6 and Architect 6, mean
Architect 6, mean 6.13. `todo.md` item 102 is to place that path without the replay. 6.13. `todo.md` item 102 is to place that path without the replay. Item 106, 2026-10-13, shipped
after three rounds under a worse ruling, the maintainer's call: both paired seats read the overall
as the same, but Locality fell to a mean of 5.13, laid on the spelling memo the chunk splits between
the CommonMark ask, which fills it, and the walk, which drops each entry as it reads it. The scores
at 1f4551a were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5. `todo.md` item 110 is to fill and
read the memo in one place.
## Properties on a fixed seed ## Properties on a fixed seed
@@ -669,17 +674,17 @@ Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29
converts alike everywhere. A writer first refuses a document whose text alone passes the bound: the converts alike everywhere. A writer first refuses a document whose text alone passes the bound: the
lossless writer spells every character of it, and the portable reduction refuses on the caller's lossless writer spells every character of it, and the portable reduction refuses on the caller's
text, edge whitespace and all, since merging that text first could build past the bound. The text, edge whitespace and all, since merging that text first could build past the bound. The
builders join as they always did, and the writer's result and the CommonMark ask's spelling are held builders join plain strings, and each block, the writer's result and the CommonMark ask's spelling
to the bound once built. V8 stops at the bound itself and SpiderMonkey at its own longest string, are held to the bound as they are built. V8 throws building the same string past the bound,
and `withinEngineLimits` refuses both throws as the same `tooLong`, so every engine answers alike. SpiderMonkey past its own longest string, and `withinEngineLimits` refuses both throws as the same
The carry's two-space JSON, which indents every value its depth over, stops at the bound unbuilt `tooLong`, so every engine stops at the same block with the same answer. The carry's two-space JSON,
(`joinedWithin`). A deep list or quote re-indents its whole text at each level before the bound which indents every value its depth over, stops at the bound unbuilt (`joinedWithin`). A deep list
refuses it (`todo.md` item 109). or quote re-indents its whole text at each level before the bound refuses it (`todo.md` item 109).
JavaScriptCore builds up to its own longest string, 2^31 − 1, before the check. A throw past that, JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. A
past any engine's longest array, or JavaScriptCore's `Out of memory`, which also reports a heap throw past that, past any engine's longest array, or JavaScriptCore's `Out of memory`, which also
running short, is refused with the engine's message. `todo.md` item 108 bounds the line's own reports a heap running short, is refused with the engine's message. `todo.md` item 108 bounds the
memory. line's own memory.
## Nothing recurses unbounded ## Nothing recurses unbounded
-17
View File
@@ -64,23 +64,6 @@ test('refuses lines their quotes would prefix past V8\'s longest string', () =>
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong) assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong)
}) })
test('refuses a directive whose opener and body together would pass V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28)
const expand: AdfNode = { attrs: { title: long }, content: [{ content: [{ text: long, type: 'text' }], type: 'codeBlock' }], type: 'expand' }
assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong)
})
test('refuses a list whose item markers would carry its lines past V8\'s longest string', () => {
const item: AdfNode = { content: [nearlyHalfTheBound()], type: 'listItem' }
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' }))), tooLong)
})
test('refuses a directive opener whose attributes together would pass V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28)
const panel: AdfNode = { attrs: { panelColor: long, panelIcon: long, panelIconText: long, panelType: 'custom' }, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }
assert.equal(markdown(adfToLosslessMarkdown(document(panel))), tooLong)
})
test('refuses text past V8\'s longest string', () => { test('refuses text past V8\'s longest string', () => {
const long = 'a'.repeat(2 ** 28) const long = 'a'.repeat(2 ** 28)
const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' }))) const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' })))
+18 -9
View File
@@ -28,7 +28,6 @@ type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`. // CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string } type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node. // Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
// Holds the spelling of each node the CommonMark ask was put to, until the walk above reads it.
export type SpellingMemo = Map<AdfNode, KeptSpelling> export type SpellingMemo = Map<AdfNode, KeptSpelling>
type WalkedItem = { node: AdfNode; walk: PlacedBlocks } type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined } export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined }
@@ -63,6 +62,9 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu
for (const [index, node] of nodes.entries()) { for (const [index, node] of nodes.entries()) {
const block = emitBlock(node, [...path, 'content', index], depth, writing) const block = emitBlock(node, [...path, 'content', index], depth, writing)
if (!block.ok) return block if (!block.ok) return block
// V8 throws building a block past the bound; an engine holding longer strings stops at the same block.
const fits = withinLongest(block.value.text)
if (!fits.ok) return fits
headroom = Math.min(headroom, block.value.headroom) headroom = Math.min(headroom, block.value.headroom)
blocks.push({ ...block.value, node }) blocks.push({ ...block.value, node })
} }
@@ -113,19 +115,19 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<null> | undefined { export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<null> | undefined {
const readable = readableBlock(node, path, depth, writing) const readable = readableBlock(node, path, depth, writing)
if (readable === undefined) writing.memo.set(node, { block: undefined, depth }) if (readable === undefined) {
else if (readable.ok) writing.memo.set(node, { block: readable.value, depth }) writing.memo.set(node, { block: undefined, depth })
if (readable === undefined) return undefined return undefined
}
if (!readable.ok) return readable if (!readable.ok) return readable
// V8 throws past the bound while building the spelling; an engine holding longer strings refuses here alike. const fits = withinLongest(readable.value.text)
if (readable.value.text.length > longestMarkdown) return tooLong() if (!fits.ok) return fits
writing.memo.set(node, { block: readable.value, depth })
return readable.value.spelling === 'directive' ? undefined : success(null) return readable.value.spelling === 'directive' ? undefined : success(null)
} }
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const kept = writing.memo?.get(node) const kept = takeKeptSpelling(writing.memo, node)
// The walk reading an entry spells the node into its own text, so the entry is dropped: kept, every level of a deep subtree would hold its whole text.
writing.memo?.delete(node)
if (kept !== undefined) { if (kept !== undefined) {
if (kept.block === undefined) return undefined if (kept.block === undefined) return undefined
// An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards. // An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards.
@@ -134,6 +136,13 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri
return spellReadableBlock(node, path, depth, writing) return spellReadableBlock(node, path, depth, writing)
} }
// The CommonMark ask fills an entry; the walk above reads it once and spells it into its own text, so kept, every level of a deep subtree would hold its whole text.
function takeKeptSpelling(memo: SpellingMemo | undefined, node: AdfNode): KeptSpelling | undefined {
const kept = memo?.get(node)
memo?.delete(node)
return kept
}
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined { function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined
if (portable !== undefined) return portable if (portable !== undefined) return portable
+1 -1
View File
@@ -2,7 +2,6 @@ import type { AdfNode } from '../adf/document.ts'
import type { DirectiveSpan, Read } from './directive-syntax.ts' import type { DirectiveSpan, Read } from './directive-syntax.ts'
import type { JsonSpelling } from '../canonical-json.ts' import type { JsonSpelling } from '../canonical-json.ts'
import type { JsonValue } from '../json-value.ts' import type { JsonValue } from '../json-value.ts'
import { joinedWithin } from './length-bound.ts'
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts' import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts' import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts' import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
@@ -10,6 +9,7 @@ import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
import { infoStringCarries } from './commonmark/grammar.ts' import { infoStringCarries } from './commonmark/grammar.ts'
import { isAdfNode } from '../adf/document.ts' import { isAdfNode } from '../adf/document.ts'
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts' import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
import { joinedWithin } from './length-bound.ts'
import { largestNesting } from '../nesting.ts' import { largestNesting } from '../nesting.ts'
import { parseJsonText } from '../json-text.ts' import { parseJsonText } from '../json-text.ts'
+3 -2
View File
@@ -262,9 +262,10 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
if (!read.ok) return read if (!read.ok) return read
const built = directiveBody(read.value, block.blocks, reading, path, depth) const built = directiveBody(read.value, block.blocks, reading, path, depth)
if (!built.ok) return built if (!built.ok) return built
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }) // Guarded here, so V8's throw past the bound refuses at the directive as another engine's check does.
if (readable === undefined) return built const readable = withinEngineLimits<null | undefined>(() => commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }) ?? success(undefined))
if (!readable.ok) return readable if (!readable.ok) return readable
if (readable.value === undefined) return built
return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
} }
@@ -76,12 +76,6 @@ test('refuses a paragraph whose text would pass V8\'s longest string, joining no
assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /') assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /')
}) })
// Two code blocks of 300 lines, some 1000 characters short of V8's longest string, so only the quote's prefixes pass it.
test('refuses an expand whose quoted lines would pass V8\'s longest string', () => {
const code: AdfNode = { content: [text(`${'a'.repeat(894_782)}\n`.repeat(300))], type: 'codeBlock' }
assert.equal(portableDocument(document(node('expand', { title: 'x' }, code, code))), 'unsupported-node-shape at /')
})
test('refuses nesting past 500 levels wherever the reduction walks', () => { test('refuses nesting past 500 levels wherever the reduction walks', () => {
const lowest = (bottom: AdfNode): string => { const lowest = (bottom: AdfNode): string => {
let deep = bottom let deep = bottom
@@ -1,7 +1,6 @@
import type { AdfDocument, AdfNode } from '../../adf/document.ts' import type { AdfDocument, AdfNode } from '../../adf/document.ts'
import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts' import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts' import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
import { centeredImage, externalMedia } from '../external-image.ts' import { centeredImage, externalMedia } from '../external-image.ts'
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts' import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts' import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
@@ -11,6 +10,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts'
import { languageSlot } from '../code-language.ts' import { languageSlot } from '../code-language.ts'
import { largestNesting } from '../../nesting.ts' import { largestNesting } from '../../nesting.ts'
import { linkDefinition } from '../mark-spellings.ts' import { linkDefinition } from '../mark-spellings.ts'
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
import { splitTaskList } from './task-list.ts' import { splitTaskList } from './task-list.ts'
import { toEditorNormal } from './editor-normal.ts' import { toEditorNormal } from './editor-normal.ts'
+17 -6
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 110` `Next ID = 111`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -33,6 +33,7 @@
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 | | 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 | | 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 | | 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
| 110 | 0.2.1 | decision | **Fill and read the spelling memo in `readableBlock` alone, dropping a spelled node's children's entries so memory stays linear.** | 3 | 3 | 2 | 6 | 2 | 10.9 |
| 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 | | 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
| 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 | | 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
| 62 | 0.2.1 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 | | 62 | 0.2.1 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
@@ -81,7 +82,9 @@ The line escaper keeps an array element per character twice (`escapings`, `place
50 bytes a character: one 20-million-character text node took 1.2 GB on Node 24, and one of 80 50 bytes a character: one 20-million-character text node took 1.2 GB on Node 24, and one of 80
million exhausted its 2.2 GB default heap. Twenty million `*` throw `RangeError: Set maximum size million exhausted its 2.2 GB default heap. Twenty million `*` throw `RangeError: Set maximum size
exceeded`, which `withinEngineLimits` lets through. `losslessMarkdownToAdf` reaches both through a exceeded`, which `withinEngineLimits` lets through. `losslessMarkdownToAdf` reaches both through a
directive's CommonMark ask. Found while shipping item 106, 2026-10-11. directive's CommonMark ask, and reading 537 MB of markdown, a `!adf:bulletList` of two code blocks
just under the length bound, took 3.4 GB on Bun, too much for the gate to test its length refusal:
add that test with this fix. Found while shipping item 106, 2026-10-11.
### 103. Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code. ### 103. Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code.
@@ -135,6 +138,15 @@ Every reader reads `![a](u "t")` as an image captioned `t` (`docs/decisions.md`
image only alone in its paragraph, 3 of 3 on 2026-10-09). On 2026-10-11 a writer panel of the same image only alone in its paragraph, 3 of 3 on 2026-10-09). On 2026-10-11 a writer panel of the same
audience kept the captioned image's `!adf:caption` directive, 3 of 3, reading a title as hover text. audience kept the captioned image's `!adf:caption` directive, 3 of 3, reading a title as hover text.
### 110. Fill and read the spelling memo in `readableBlock` alone, dropping a spelled node's children's entries so memory stays linear.
Item 106 made the CommonMark ask fill the memo and the walk above drop each entry as it reads it, so
a deep subtree holds no level's text twice. Every comprehension seat read that split, across
`commonMarkSpelling`, `readableBlock` and their three callers, as order-dependent state, and the
chunk shipped under a worse ruling on Locality. Keep main's single reader and writer instead: when
`readableBlock` keeps a node's spelling, it drops its children's and grandchildren's entries, whose
text the node's now holds. Found by the comprehension check of item 106, 2026-10-13.
### 60. Collect the questions `parse/` asks `emit/` into one named module. ### 60. Collect the questions `parse/` asks `emit/` into one named module.
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported `parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
@@ -212,10 +224,9 @@ the reason the project read worse.
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the Each level of a nested list or quote re-indents its whole text, so the work is the depth times the
text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside
`!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a `!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a
list nested 480 levels took 11 minutes to refuse on Node 24. A deep subtree past the length bound list nested 480 levels took 11 minutes to refuse on Node 24: a deep subtree past the length bound
refuses only once every level below has re-indented its text, so no test reaches refuses only once every level below has re-indented its text. The writers do the same on such a
`losslessMarkdownToAdf`'s length refusal in seconds: add one with this fix. The writers do the same document. Found while shipping item 106, 2026-10-11.
on such a document. Found while shipping item 106, 2026-10-11.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.