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

Return the new code engine-limit where the engine runs short of memory or passes its longest array, so unsupported-node-shape stays deterministic

This commit is contained in:
2026-10-13 16:38:48 +02:00
parent aa54361eb0
commit ecd695a6d1
7 changed files with 33 additions and 17 deletions
+5 -1
View File
@@ -9,6 +9,9 @@
code. `MIGRATION.md` says which call to take.
- **Breaking:** the markdown readers refuse a value that is not a string with the new code
`not-a-string`; `MIGRATION.md` lists what each value did before.
- **Breaking:** every call returns the new code `engine-limit` where the JavaScript engine runs
short of memory or passes its longest array, and, on JavaScriptCore, its longest string. A retry
may succeed. Every other refusal stays deterministic.
- **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a
`Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
@@ -90,7 +93,8 @@
`losslessMarkdownToAdf` refuses it as `unsupported-node-shape`, where a few megabytes of nested
list lines ran the call out of memory.
- Fix the markdown readers past the longest string or array the JavaScript engine builds: they
return `unsupported-node-shape` where they threw.
return `unsupported-node-shape` past V8's or SpiderMonkey's longest string, and `engine-limit`
past the rest, where they threw.
- Fix a paragraph of tens of millions of characters: `adfToLosslessMarkdown` and
`adfToPortableMarkdown` write it in a few bytes of memory a character beyond the document and its
markdown, where they took about 50, so 80 million characters ran Node's default heap out of memory
+3 -2
View File
@@ -88,8 +88,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
### Error codes
`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave
`ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the four stops compiling,
and one naming every code needs the new case. A document `adfToMarkdown` refused with one of the
`ConvertErrorCode` and `engine-limit` and `not-a-string` join it: a `switch` naming one of the four
stops compiling, and one naming every code needs the new cases. A document `adfToMarkdown` refused with one of the
three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown the parser refused
with one of the four now reads, or refuses with the code the table gives.
@@ -114,3 +114,4 @@ with one of the four now reads, or refuses with the code the table gives.
| `null`, `undefined` or a non-empty array, on parse | throws a `TypeError` | `not-a-string` |
| a `String` object, on parse | the document its text spells | `not-a-string`; pass `String(value)` |
| any other value that is not a string, such as a number or `{}`, on parse | an empty document | `not-a-string` |
| a conversion that runs the JavaScript engine short of memory or past its longest array | throws a `RangeError`, or the engine ends the process | `engine-limit`; retry it once |
+8 -8
View File
@@ -210,11 +210,11 @@ Each call's name says which markdown it reads or writes:
| Call | Markdown | Refuses with |
| --- | --- | --- |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
@@ -410,9 +410,8 @@ 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. The exception is memory running
short, which returns `unsupported-node-shape` at the empty path, the code and path of markdown past
the length bound. Retry once any call that returns that; a repeat is a lasting refusal.
the identical error: fix the input, or set the document aside. The exception is `engine-limit`,
whose causes include memory running short: retry it once, and treat a repeat as lasting.
### Parsing
@@ -444,8 +443,9 @@ The emitter raises these, and so does a parse, which asks the emitter how to spe
| Code | Fires when | What you can do |
| --- | --- | --- |
| `engine-limit` | in every call: a conversion that passes the longest array the JavaScript engine builds or runs it short of memory, and on JavaScriptCore (Bun, Safari) one that passes its longest string, 2^31 − 1 characters | retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
| `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 one that, written out as plain CommonMark, would be longer than 536870888 characters, 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 536870888 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, or runs it short of memory | 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 536870888 characters or the engine's limit, retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
| `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 one that, written out as plain CommonMark, would be longer than 536870888 characters, 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 536870888 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 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 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
## The guarantees
+7 -1
View File
@@ -467,6 +467,11 @@ handles one cause alike whichever node, attribute or direction raised it.
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).
- A conversion the engine stops, by running short of memory or passing its longest array, is
`engine-limit`, at the empty path. A retry may pass it, so `unsupported-node-shape`, which never
does, cannot hold it. JavaScriptCore throws the same `Out of memory` past its longest string,
which takes `engine-limit` too: telling the two apart would mean reading the engine's message
(2026-10-13, the maintainer).
- 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).
@@ -682,7 +687,8 @@ carry's two-space JSON, which indents every value its depth over, stops at the b
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
that, JavaScriptCore throws `Out of memory`, as it does when its heap runs short. That throw, and
any engine's throw past its longest array, is refused with the engine's message.
any engine's throw past its longest array or out of memory, is `engine-limit` (§Which code a cause
takes).
## Nothing recurses unbounded
+8 -4
View File
@@ -23,15 +23,19 @@ test('passes a text within the bound', () => {
assert.deepEqual(withinLongest('x'), success('x'))
})
test('refuses a string past the bound as too long on every engine, and names any other engine limit', () => {
test('refuses a string past the bound as too long on every engine, and any other engine limit as engine-limit', () => {
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Invalid string length')), tooLong())
assert.deepEqual(withinEngineLimits(thrown('InternalError', 'allocation size overflow')), tooLong())
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Out of memory')), {
error: { code: 'unsupported-node-shape', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
error: { code: 'engine-limit', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
ok: false,
})
assert.equal(withinEngineLimits(thrown('RangeError', 'Invalid array length')).ok, false)
assert.equal(withinEngineLimits(thrown('RangeError', 'Array buffer allocation failed')).ok, false)
const code = (message: string): string | undefined => {
const refused = withinEngineLimits(thrown('RangeError', message))
return refused.ok ? undefined : refused.error.code
}
assert.equal(code('Invalid array length'), 'engine-limit')
assert.equal(code('Array buffer allocation failed'), 'engine-limit')
assert.deepEqual(
withinEngineLimits(() => success('x')),
{ ok: true, value: 'x' },
+1 -1
View File
@@ -36,6 +36,6 @@ export function withinEngineLimits<T>(convert: () => Result<T>): Result<T> {
if (pastLongestString.includes(thrown)) return tooLong()
// A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw.
if (!pastEngineLimit.includes(thrown)) throw error
return failure('unsupported-node-shape', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
return failure('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
}
}
+1
View File
@@ -1,4 +1,5 @@
export type ConvertErrorCode =
| 'engine-limit'
| 'malformed-directive'
| 'malformed-pipe-table'
| 'not-a-string'