From 80b4e0a9b7a052d7240ec1a843e3ccae6e7ed1b9 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 11 Oct 2026 20:51:21 +0200 Subject: [PATCH] Answer stability round 1: the docs name V8's array limit in one text node, a test pins Invalid array length, and item 106 takes the readers' RangeError and JavaScriptCore's Out of memory --- CHANGELOG.md | 10 ++++++---- README.md | 2 +- docs/decisions.md | 6 +++--- src/markdown/emit/adf-to-markdown.test.ts | 2 +- src/result.test.ts | 16 ++++++++++++++-- src/result.ts | 2 +- todo.md | 4 +++- 7 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9ea8cd..ec6598d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,10 +83,12 @@ - Fix a cyclic document, in which an object holds itself: `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. -- Fix a document whose markdown runs past the longest string the JavaScript engine holds, about 537 - million characters on V8: `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse it as - `unsupported-node-shape`, where they threw. `losslessMarkdownToAdf` refuses compact JSON in an - `adf:` fence in time linear in it, where a megabyte of deeply nested JSON threw. +- Fix a document whose markdown would be longer than the longest string or array the JavaScript + engine builds, on V8 about 537 million characters, or about 134 million in one text node: + `adfToLosslessMarkdown` and `adfToPortableMarkdown` refuse it as `unsupported-node-shape`, where + they threw. +- Fix compact JSON in an `adf:` fence: `losslessMarkdownToAdf` refuses it as + `unsupported-node-shape`, where a megabyte of deeply nested JSON 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 acaa3de..d833de7 100644 --- a/README.md +++ b/README.md @@ -442,7 +442,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 both writers: a document whose markdown runs past the longest string the JavaScript engine holds, about 537 million characters on V8 | 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. Past the engine's longest string, keep the ADF and show the document 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. Past the engine's longest string, 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 ae6f9fc..75d2aa4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -461,9 +461,9 @@ handles one cause alike whichever node, attribute or direction raised it. knows which direction it called and parting them after `0.1.0` is MAJOR (2026-09-23). - A non-finite number takes two codes: `unsupported-node-shape` parsing, `not-an-adf-document` emitting — no document holds one, so no round-trip crosses them (2026-09-23). -- A document whose markdown runs past the longest string the JavaScript engine holds 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, +- 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). - The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at diff --git a/src/markdown/emit/adf-to-markdown.test.ts b/src/markdown/emit/adf-to-markdown.test.ts index ec283fe..584ba7a 100644 --- a/src/markdown/emit/adf-to-markdown.test.ts +++ b/src/markdown/emit/adf-to-markdown.test.ts @@ -49,7 +49,7 @@ test('refuses a document whose markdown runs past the engine\'s longest string', let value: JsonValue = new Array(2_200_000).fill(0) for (let level = 0; level < 495; level += 1) value = [value] const result = adfToLosslessMarkdown(document({ attrs: { value }, type: 'unknownBlock' })) - assert.match(markdown(result), /^unsupported-node-shape: the output runs past what this JavaScript engine holds: /) + assert.match(markdown(result), /^unsupported-node-shape: the output would be longer than the longest string or array this JavaScript engine builds \(/) assert.deepEqual(path(result), []) }) diff --git a/src/result.test.ts b/src/result.test.ts index dd0c451..55749b6 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -4,7 +4,11 @@ import { fileURLToPath } from 'node:url' import { readFileSync, readdirSync } from 'node:fs' import test from 'node:test' -import { success, withinEngineLimits } from './result.ts' +import { success, withinEngineLimits, type Result } from './result.ts' + +function code(result: Result): string { + return result.ok ? `converted ${result.value}` : result.error.code +} const sourceRoot = dirname(fileURLToPath(import.meta.url)) const union = /export type ConvertErrorCode =\n((?:\s+\| '[a-z-]+'\n)+)/ @@ -39,7 +43,15 @@ test('refuses where SpiderMonkey runs out of string, and lets a stack overflow o withinEngineLimits(() => { throw overflow }), - { error: { code: 'unsupported-node-shape', message: 'the output runs past what this JavaScript engine holds: allocation size overflow', path: [] }, ok: false }, + { error: { code: 'unsupported-node-shape', message: 'the output would be longer than the longest string or array this JavaScript engine builds (allocation size overflow)', path: [] }, ok: false }, + ) + assert.equal( + code( + withinEngineLimits(() => { + throw new RangeError('Invalid array length') + }), + ), + 'unsupported-node-shape', ) assert.throws(() => withinEngineLimits(() => { diff --git a/src/result.ts b/src/result.ts index db878eb..dd524b8 100644 --- a/src/result.ts +++ b/src/result.ts @@ -52,6 +52,6 @@ export function withinEngineLimits(convert: () => Result): Result { } catch (error) { // A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw. if (!(error instanceof Error) || !engineLimitThrows.includes(`${error.name}: ${error.message}`)) throw error - return failure('unsupported-node-shape', `the output runs past what this JavaScript engine holds: ${error.message}`, []) + return failure('unsupported-node-shape', `the output would be longer than the longest string or array this JavaScript engine builds (${error.message})`, []) } } diff --git a/todo.md b/todo.md index c97be16..138ac05 100644 --- a/todo.md +++ b/todo.md @@ -81,7 +81,9 @@ of markdown, a `!adf:blockquote` holding a list nested 490 levels whose paragrap 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. The length to refuse past is a number no entry states yet (AGENTS.md §Stated numbers): +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.