diff --git a/README.md b/README.md index b2f09f7..acaa3de 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 | +| `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 | ## The guarantees diff --git a/docs/decisions.md b/docs/decisions.md index 2092611..ae6f9fc 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -463,7 +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 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 (2026-10-11). + 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 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 00e4d7f..ebde960 100644 --- a/src/canonical-json.ts +++ b/src/canonical-json.ts @@ -8,7 +8,10 @@ type Pending = string | { depth: number; value: JsonValue } export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { const text: string[] = [] - writeCanonicalJson(value, spelling, (piece) => text.push(piece) > 0) + writeCanonicalJson(value, spelling, (piece) => { + text.push(piece) + return true + }) return text.join('') } diff --git a/src/result.test.ts b/src/result.test.ts index 27ee428..dd0c451 100644 --- a/src/result.test.ts +++ b/src/result.test.ts @@ -32,7 +32,7 @@ test('every ConvertErrorCode is the code of a production call site, and every ca assert.deepEqual(calledCodes(), declaredCodes()) }) -test('refuses where SpiderMonkey runs out of string, and lets any other throw through', () => { +test('refuses where SpiderMonkey runs out of string, and lets a stack overflow or any other throw through', () => { const overflow = new Error('allocation size overflow') overflow.name = 'InternalError' assert.deepEqual( @@ -41,6 +41,11 @@ test('refuses where SpiderMonkey runs out of string, and lets any other throw th }), { error: { code: 'unsupported-node-shape', message: 'the output runs past what this JavaScript engine holds: allocation size overflow', path: [] }, ok: false }, ) + assert.throws(() => + withinEngineLimits(() => { + throw new RangeError('Maximum call stack size exceeded') + }), + ) assert.throws(() => withinEngineLimits(() => { throw new TypeError('a bug') diff --git a/src/result.ts b/src/result.ts index 8d859c2..9abec57 100644 --- a/src/result.ts +++ b/src/result.ts @@ -42,12 +42,15 @@ export function success(value: T): { ok: true; value: T } { return { ok: true, value } } -// Any string concatenation throws past the engine's longest string: RangeError in V8 and JavaScriptCore, InternalError in SpiderMonkey. +// Measured past the longest string or array: V8 throws the first two, JavaScriptCore the third, SpiderMonkey the fourth. A stack overflow stays a throw. +const engineLimitFaults = ['RangeError: Invalid array length', 'RangeError: Invalid string length', 'RangeError: Out of memory', 'InternalError: allocation size overflow'] + +// Any string concatenation can throw one, so this wraps a whole conversion. export function withinEngineLimits(convert: () => Result): Result { try { return convert() } catch (error) { - if (!(error instanceof RangeError || (error instanceof Error && error.name === 'InternalError'))) throw error + if (!(error instanceof Error) || !engineLimitFaults.includes(`${error.name}: ${error.message}`)) throw error return failure('unsupported-node-shape', `the output runs past what this JavaScript engine holds: ${error.message}`, []) } } diff --git a/todo.md b/todo.md index 878e03f..c97be16 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 106` +`Next ID = 108` | Goal | W | |---|---| @@ -25,6 +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 | | 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 | @@ -52,6 +53,7 @@ | 81 | 0.3.0 | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 2 | 5 | 5 | 3 | 16.3 | | 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 | | 72 | 0.3.0 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 2 | 1 | 3 | 6 | 3 | 13.1 | +| 107 | 0.3.0 | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 4 | 5 | 3 | 5 | 1 | 10.8 | | 51 | 0.3.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 4 | 2 | 7 | 4, 5 | 10.4 | | 95 | 0.3.0 | decision | **Find the code-span closers an escape forms, the mark runs a line cannot spell and the text `[` that opens link text by reading the line back, in place of `escapeClosedRuns`, `unspellableRuns` and `lastLinkClose` predicting them.** | 5 | 6 | 2 | 6 | 1 | 9.5 | | 91 | 0.3.0 | | **Spell an emphasis or link once across the hard breaks between two nodes it marks, in both writers.** | 5 | 5 | 5 | 6 | 6 | 9.0 | @@ -71,6 +73,18 @@ ## Details +### 106. Refuse a document or markdown whose spelling 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 +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): +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. + ### 103. Fold `unspellable-whitespace` into `unsupported-node-shape`, so a line ending and a null character in a content slot take one code. `slotFault` in `directive-syntax.ts` gives a newline in an `emoji`, `mention` or `status` content @@ -295,6 +309,14 @@ Gitea copy holds no issues or releases to move. Actions stay off on the mirror: otherwise run there. When this lands, the `docs/decisions.md` entry "GitHub is canonical" loses its 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. + ### 51. Match a reference label to its definition under Unicode case folding. `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec