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

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

This commit is contained in:
2026-10-11 20:51:21 +02:00
committed by Lillem4n
parent edacef410f
commit 80b4e0a9b7
7 changed files with 29 additions and 13 deletions
+6 -4
View File
@@ -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,
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -49,7 +49,7 @@ test('refuses a document whose markdown runs past the engine\'s longest string',
let value: JsonValue = new Array<JsonValue>(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), [])
})
+14 -2
View File
@@ -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>): 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(() => {
+1 -1
View File
@@ -52,6 +52,6 @@ export function withinEngineLimits<T>(convert: () => Result<T>): Result<T> {
} 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})`, [])
}
}
+3 -1
View File
@@ -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.