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

Answer architecture round 1: the guard refuses only the measured overflow errors, the README keeps such a document read-only, and items 106 and 107 take the heap and the carry's indentation

This commit is contained in:
2026-10-11 20:40:21 +02:00
parent 88773d50b9
commit 0227c0b0cc
6 changed files with 42 additions and 7 deletions
+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 | | 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-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 ## The guarantees
+3 -1
View File
@@ -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). 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 - 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, `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 - 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 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). `not-an-adf-document` names the emitters' input (2026-10-05).
+4 -1
View File
@@ -8,7 +8,10 @@ type Pending = string | { depth: number; value: JsonValue }
export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string { export function serializeCanonicalJson(value: JsonValue, spelling: JsonSpelling): string {
const text: string[] = [] const text: string[] = []
writeCanonicalJson(value, spelling, (piece) => text.push(piece) > 0) writeCanonicalJson(value, spelling, (piece) => {
text.push(piece)
return true
})
return text.join('') return text.join('')
} }
+6 -1
View File
@@ -32,7 +32,7 @@ test('every ConvertErrorCode is the code of a production call site, and every ca
assert.deepEqual(calledCodes(), declaredCodes()) 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') const overflow = new Error('allocation size overflow')
overflow.name = 'InternalError' overflow.name = 'InternalError'
assert.deepEqual( 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 }, { 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(() => assert.throws(() =>
withinEngineLimits(() => { withinEngineLimits(() => {
throw new TypeError('a bug') throw new TypeError('a bug')
+5 -2
View File
@@ -42,12 +42,15 @@ export function success<T>(value: T): { ok: true; value: T } {
return { ok: true, value } 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<T>(convert: () => Result<T>): Result<T> { export function withinEngineLimits<T>(convert: () => Result<T>): Result<T> {
try { try {
return convert() return convert()
} catch (error) { } 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}`, []) return failure('unsupported-node-shape', `the output runs past what this JavaScript engine holds: ${error.message}`, [])
} }
} }
+23 -1
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 106` `Next ID = 108`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -25,6 +25,7 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score | | 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 | | 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 | | 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 | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 ## 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. ### 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 `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 otherwise run there. When this lands, the `docs/decisions.md` entry "GitHub is canonical" loses its
interim sentence. 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. ### 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 `link-syntax.ts` normalizes a label with `toLowerCase`, so `[ẞ]` misses its `[SS]` definition (spec