From 62d80a69b729a90dd60334dcdf0530c9ca16a214 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 20:54:59 +0200 Subject: [PATCH] Answer prose round 1: the README says which refusal differs between engines, items 106 and 107 read one idea a sentence, and two test titles and a comment read on one pass --- CHANGELOG.md | 2 +- README.md | 7 ++-- docs/decisions.md | 6 ++-- src/canonical-json.ts | 3 +- src/markdown/parse/markdown-to-adf.test.ts | 2 +- .../portable/adf-to-portable-markdown.test.ts | 2 +- todo.md | 32 +++++++++---------- 7 files changed, 27 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e75771..88f6263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,7 +88,7 @@ `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse it as `unsupported-node-shape`, where they threw. - Fix deeply nested compact JSON in an `adf:` fence: `losslessMarkdownToAdf` refuses it as - `unsupported-node-shape`, like any compact JSON there, where a megabyte of it threw. + `unsupported-node-shape`, as it refuses any compact JSON there, where about a megabyte of it threw. - Add `adfToPortableMarkdown` and `portableMarkdownToAdf`, a lossy pair converting ADF to and from markdown GitHub, GitLab and Obsidian render: alerts, callouts, task lists, `==highlights==` and pipe tables. A task item keeps its paragraphs, then its nested task lists; the rest of the item, diff --git a/README.md b/README.md index a52d597..62de305 100644 --- a/README.md +++ b/README.md @@ -408,8 +408,9 @@ A parse always names a position, so the three markdown readers return `ParseErro typed on `ConvertError` takes both. A call reports the first refusal in document order and stops, so a document with several surfaces -them one per call. Every refusal is deterministic — there is no I/O anywhere — so a retry returns -the identical error: fix the input, or set the document aside. +them one per call. Every refusal is deterministic on one engine — there is no I/O anywhere — so a +retry returns the identical error: fix the input, or set the document aside. Only the engine-limit +refusal under `unsupported-node-shape` differs between engines. ### Parsing @@ -442,7 +443,7 @@ The emitter raises these, and so does a parse, which asks the emitter how to spe | 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-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. 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 the longest string or array the JavaScript engine builds — on V8, about 537 million characters of markdown, or about 134 million in one text node | 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. Past the engine's longest string or array, keep the ADF and pass the document 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. 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 the longest string or array the JavaScript engine builds — on V8, about 537 million characters of markdown, or about 134 million in one text node | 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 the engine's longest string or array, keep the ADF and pass the document over, or show it read-only | ## The guarantees diff --git a/docs/decisions.md b/docs/decisions.md index 18c2976..19ebd7a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -463,9 +463,9 @@ handles one cause alike whichever node, attribute or direction raised it. emitting — no document holds one, so no round-trip crosses them (2026-09-23). - A document whose markdown would be longer than the longest string or array the JavaScript engine builds is `unsupported-node-shape` at the document's path, in either writer: the document does - not convert, and the bound is the engine's own. A carried node reaches it on about 1 MB of - deeply nested JSON, since the block carry indents two spaces a level; `todo.md` item 107 spells - it in a length linear in its JSON (2026-10-11). + not convert, and no spelling in the input causes the bound (§The code list). A carried node + passes it on about 1 MB of deeply nested JSON, since the block carry indents two spaces a level; + `todo.md` item 107 respells the carry in a length linear in its JSON (2026-10-11). - The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and `not-an-adf-document` names the emitters' input (2026-10-05). diff --git a/src/canonical-json.ts b/src/canonical-json.ts index ebde960..b67f8f3 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -15,7 +15,7 @@ export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling) return text.join('') } -// Stops at the first piece `text` does not hold: two-space indentation can spell a short compact text past the engine's longest string. +// Stops at the first piece that does not match `text`: re-spelled with two-space indentation, a short compact text can pass the engine's longest string. export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, text: string): boolean { let offset = 0 const whole = writeCanonicalJson(value, spelling, (piece) => { @@ -26,7 +26,6 @@ export function spellsCanonicalJson(value: JsonValue, spelling: JsonSpelling, te return whole && offset === text.length } -// `write` returns false to stop the walk, and the walk then returns false. function writeCanonicalJson(value: JsonValue, spelling: JsonSpelling, write: (piece: string) => boolean): boolean { const indent = spelling === 'compact' ? '' : ' ' const pending: Pending[] = [{ depth: 0, value }] diff --git a/src/markdown/parse/markdown-to-adf.test.ts b/src/markdown/parse/markdown-to-adf.test.ts index 23090f6..dd42512 100644 --- a/src/markdown/parse/markdown-to-adf.test.ts +++ b/src/markdown/parse/markdown-to-adf.test.ts @@ -448,7 +448,7 @@ test('names the canonical spelling a carried JSON reads alone', () => { assert.equal(content(losslessMarkdownToAdf('!adf:carry{json="{\\"type\\":\\"blockCard\\",\\"attrs\\":{}}"}\n')), `${canonically}compact, keys sorted${literal}`) }) -test('refuses compact JSON in the carry fence without spelling it two-space past the engine\'s longest string', () => { +test('refuses compact JSON in the carry fence without building its two-space spelling', () => { const json = `{"attrs":{"value":${'['.repeat(495)}${new Array(2_200_000).fill(0).join(',')}${']'.repeat(495)}}}` assert.equal( content(losslessMarkdownToAdf(`\`\`\`adf:unknownBlock\n${json}\n\`\`\`\n`)), diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index e6571ab..de11c2a 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -70,7 +70,7 @@ test('refuses what the document guard refuses, and nothing else', () => { assert.match(portableDocument(document(paragraph(deepInline))), /^unsupported-nesting-depth at /) }) -test('refuses a document whose text merges past the engine\'s longest string', () => { +test('refuses a paragraph whose merged text passes the engine\'s longest string', () => { const long = 'a'.repeat(2 ** 28) const merged = paragraph(...Array.from({ length: 9 }, () => text(long))) assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /') diff --git a/todo.md b/todo.md index 138ac05..679fc0c 100644 --- a/todo.md +++ b/todo.md @@ -25,7 +25,7 @@ | ID | Release | Exempt | Item | R | S | A | G | Goals | Score | |---|---|---|---|---|---|---|---|---|---| -| 106 | 0.2.0 | defect | **Refuse a document or markdown whose spelling would pass a stated length before building it, so no call exhausts the heap.** | 4 | 6 | 5 | 8 | 1, 9 | 20.5 | +| 106 | 0.2.0 | defect | **Refuse a document or markdown whose output would pass a stated length before building it, so no call exhausts the heap.** | 4 | 6 | 5 | 8 | 1, 9 | 20.5 | | 103 | 0.2.0 | | **Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code.** | 2 | 2 | 5 | 6 | 1 | 19.5 | | 84 | 0.2.0 | | **Read GFM's pipe table without leading pipes, and with alignment colons, in `portableMarkdownToAdf`, dropping the alignment.** | 3 | 4 | 7 | 7 | 4, 7 | 19.4 | | 52 | 0.2.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 | @@ -73,19 +73,19 @@ ## Details -### 106. Refuse a document or markdown whose spelling would pass a stated length before building it, so no call exhausts the heap. +### 106. Refuse a document or markdown whose output would pass a stated length before building it, so no call exhausts the heap. The writers return `unsupported-node-shape` where the engine throws past its longest string, but -some documents exhaust the heap first, which no catch survives. `losslessMarkdownToAdf` on 2.1 MB -of markdown, a `!adf:blockquote` holding a list nested 490 levels whose paragraph runs on in 700,000 +some documents exhaust the heap first, which no catch survives. `losslessMarkdownToAdf` on 2.1 MB of +markdown, a `!adf:blockquote` holding a list nested 490 levels whose paragraph runs on in 700,000 lazy lines `b\`, crashed Node 24 out of heap: `directiveNode` re-spells the subtree, indenting each -line to its depth. `adfToLosslessMarkdown` on nine text nodes sharing one 2^28-character string -got Bun OOM-killed under 8 GB: `escape` in `line-escaping.ts` pushes one array element per -character. Where the heap holds out, the readers' re-spelling can still throw a `RangeError`, and -JavaScriptCore's `Out of memory`, which the writers refuse, may also mean a failed allocation a -retry survives. The length to refuse past is a number no entry states yet (AGENTS.md §Stated numbers): -V8's 2^29 − 24 on every engine, so the same document converts alike everywhere, is the candidate. -Found by the principles audit of item 83, 2026-10-11. +line to its depth. `adfToLosslessMarkdown` on nine text nodes sharing one 2^28-character string got +Bun OOM-killed under 8 GB: `escape` in `line-escaping.ts` pushes one array element per character. +Where the heap holds out, the readers' re-spelling can still throw a `RangeError`. JavaScriptCore's +`Out of memory`, which the writers refuse, may also mean a failed allocation that a retry survives. +The candidate length to refuse past is V8's 2^29 − 24 on every engine, so the same document converts +alike everywhere; no entry states it yet (AGENTS.md §Stated numbers). Found by the principles audit +of item 83, 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. @@ -313,11 +313,11 @@ interim sentence. ### 107. Spell the block carry's JSON so its length stays linear in the node's JSON. -The block carry indents two spaces a level, so a carried node's JSON about 1 MB deep in nested -arrays spells past V8's longest string, which the writers refuse as `unsupported-node-shape`; the -inline carry's compact JSON would fit. §The code list calls a refusal no spelling recovers from a -gap in the flavour. A format change: §The formats are API. Found by the README-goals audit of item -83, 2026-10-11. +The block carry indents two spaces a level, so about 1 MB of deeply nested arrays in a carried +node's JSON spells past V8's longest string, which the writers refuse as `unsupported-node-shape`; +the inline carry's compact JSON would fit. docs/decisions.md §The code list calls a refusal no +spelling recovers from a gap in the flavour. Respelling the carry changes the format (§The formats +are API). Found by the README-goals audit of item 83, 2026-10-11. ### 51. Match a reference label to its definition under Unicode case folding.