diff --git a/MIGRATION.md b/MIGRATION.md index d924fde..ac170b8 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -114,4 +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 past the JavaScript engine's longest array, `Map` or `Set`, or finding no memory for a buffer | throws a `RangeError` | `engine-limit`; retry it once | +| a conversion past the JavaScript engine's longest array, `Map` or `Set`, or finding no memory for a buffer, and on JavaScriptCore one running its heap short or past its longest string | throws | `engine-limit`; retry it once | diff --git a/docs/comprehension-history.md b/docs/comprehension-history.md index ce53e52..80eb47f 100644 --- a/docs/comprehension-history.md +++ b/docs/comprehension-history.md @@ -711,3 +711,34 @@ Inherited architect, decided by: - Shape, Locality: `src/markdown/emit/line-escaping.ts:229` `escapeClosedRuns`: mutates its input in place and needs a two-line comment arguing it is safe; output joined in batches of 4096 - Locality, Shape: `src/markdown/portable/adf-to-portable-markdown.ts:128` `portableSequence`: a refusal propagates with its source path, where it was swallowed - Shape: `src/markdown/emit/inline-line.ts:235` `emitLeaf`: one place refuses content and text, and a generator replaces the boolean-callback JSON writer + +## 2026-10-13T15:35:32Z, PR #37 at 025ccc0, against 7a27f6b + +Ruling: worse + +| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall | +|---|---|---|---|---|---| +| Mid A | better | worse | same | same | worse | +| Inherited architect | better | worse | same | worse | worse | + +Mid A, decided by: + +- Locality: `src/markdown/emit/adf-to-markdown.ts:58` `walkBlocks`: the length bound is enforced at six sites across four files, each comment giving only its own reason, where one wrapper covered the conversion +- Locality: `src/markdown/emit/adf-to-markdown.ts:126` `readableBlock`: the memo is emptied on read and filled only when `asked`, so whether a spelling is cached depends on call order and caller +- Navigation: `src/markdown/length-bound.ts:1` `withinEngineLimits`: a refused long document leads to a file named for the problem, holding the bound, both throw lists and both messages +- Locality: `src/markdown/parse/markdown-to-adf.ts:266` `directiveNode`: `success(undefined)` and `success(null)` encode the ask's answer, decoded only by tracing commonMarkSpelling +- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: a generator replaces a boolean-continue callback +- Shape: `src/markdown/emit/line-escaping.ts:38` `PlacedSegment`: segment placements drop the cursor arithmetic two functions repeated +- Shape: `src/markdown/emit/line-escaping.ts:113` `backslashed`: byte flags, codes and 4096-piece joins read harder than a character loop +- Locality: `src/markdown/portable/adf-to-portable-markdown.ts:128` `portableSequence`: a refused spelling propagates where it was silently swallowed + +Inherited architect, decided by: + +- Locality: `src/markdown/emit/adf-to-markdown.ts:58` `walkBlocks`: one rule, no markdown past longestMarkdown, is checked at about seven places across four files +- Locality, Shape: `src/markdown/emit/adf-to-markdown.ts:126` `readableBlock`: a read deletes the entry and a bare positional `asked` boolean decides what is stored, so results depend on call order +- Locality, Self-sufficiency: `src/markdown/emit/line-escaping.ts:113` `backslashed`: flags, byte codes, a lookup closure and batched joins take more passes to read as putting a backslash before characters +- Self-sufficiency: `src/markdown/emit/adf-to-markdown.ts:65` `walkBlocks`: the refusal's comment makes sense only once you know V8's limit applies on every engine, which lives in docs/decisions.md; tooLong carries path [] though the walk knows the path +- Shape: `src/markdown/length-bound.ts:17` `tooLong`: the length refusal keeps unsupported-node-shape while engine-limit got a name of its own; directiveNode folds three outcomes into one Result to fit the wrapper +- Navigation: `src/markdown/length-bound.ts:37` `withinEngineLimits`: the engine guard moves out of the Result module, and engine-limit leads from the symptom to it +- Shape, Locality: `src/markdown/portable/adf-to-portable-markdown.ts:128` `portableSequence`: a refusal propagates with its block's path, where it was silently swallowed +- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: a generator, emitLeaf's one refusal and PlacedSegment each read simpler than before diff --git a/docs/decisions.md b/docs/decisions.md index fdc41a1..9ecbe2b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -624,7 +624,7 @@ after three rounds under a worse ruling, the maintainer's call: both paired seat as the same, but Locality fell to a mean of 5.13, laid on the spelling memo the chunk splits between the CommonMark ask, which fills it, and the walk, which drops each entry as it reads it. The overall scores at 1f4551a were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean 5.63. -`todo.md` item 110 is to fill and read the memo in one place. +Item 108's chunk, 2026-10-13, fills and reads the memo in `readableBlock` alone. ## Properties on a fixed seed diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index 3281e31..b0b58fb 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -108,22 +108,23 @@ function interruptsParagraph(node: AdfNode): boolean { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result { const model = blockNodeModel(node.type) if (model === undefined) return carriedFence(carriedBlock(node, path, depth)) - const readable = readableBlock(node, path, depth, writing, false) + const readable = readableBlock(node, path, depth, writing, 'walked') if (readable !== undefined) return readable return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing)) } -export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result | undefined { - const readable = readableBlock(node, path, depth, writing, true) - if (readable === undefined) return undefined +// Whether the node takes a CommonMark spelling, rather than its directive. +export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result { + const readable = readableBlock(node, path, depth, writing, 'asked') + if (readable === undefined) return success(false) if (!readable.ok) return readable const fits = withinLongest(readable.value.text) if (!fits.ok) return fits - return readable.value.spelling === 'directive' ? undefined : success(null) + return success(readable.value.spelling !== 'directive') } // Only an asked node's spelling is kept, and reading it drops it: the walk above spells the node into its own text, so no level of a subtree holds that text twice. -function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, asked: boolean): Result | undefined { +function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, caller: 'asked' | 'walked'): Result | undefined { const { memo } = writing const kept = memo?.get(node) memo?.delete(node) @@ -133,8 +134,8 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth }) } const spelled = spellReadableBlock(node, path, depth, writing) - if (asked && spelled === undefined) memo?.set(node, { block: undefined, depth }) - if (asked && spelled?.ok === true) memo?.set(node, { block: spelled.value, depth }) + if (caller === 'asked' && spelled === undefined) memo?.set(node, { block: undefined, depth }) + if (caller === 'asked' && spelled?.ok === true) memo?.set(node, { block: spelled.value, depth }) return spelled } diff --git a/src/markdown/length-bound.test.ts b/src/markdown/length-bound.test.ts index d96f9ed..fb7d14f 100644 --- a/src/markdown/length-bound.test.ts +++ b/src/markdown/length-bound.test.ts @@ -39,6 +39,12 @@ test('refuses a string past the bound as too long on every engine, and any other assert.equal(code('Map maximum size exceeded'), 'engine-limit') assert.equal(code('Set maximum size exceeded'), 'engine-limit') assert.equal(code('invalid array length'), 'engine-limit') + assert.deepEqual( + withinEngineLimits(() => { + throw 'out of memory' + }), + { 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.deepEqual( withinEngineLimits(() => success('x')), { ok: true, value: 'x' }, diff --git a/src/markdown/length-bound.ts b/src/markdown/length-bound.ts index bafb3b0..cef69ca 100644 --- a/src/markdown/length-bound.ts +++ b/src/markdown/length-bound.ts @@ -1,6 +1,6 @@ import { failure, success, type Result } from '../result.ts' -// V8's longest string (docs/decisions.md §Markdown past V8's longest string is refused on every engine). +// V8's longest string, refused on every engine so a document converts alike everywhere (docs/decisions.md §Markdown past V8's longest string is refused on every engine). export const longestMarkdown = 2 ** 29 - 24 // Measured on Firefox 155, Node 24 and Bun 1.4, and SpiderMonkey's array length from its js.msg. SpiderMonkey's overflow and V8's string overflow both mean a string past longestMarkdown; the others name an engine's own limit or its memory running short. @@ -38,11 +38,17 @@ export function withinEngineLimits(convert: () => Result): Result { try { return convert() } catch (error) { + // SpiderMonkey throws a bare string where it finds no memory for a buffer (measured on Firefox 155). + if (error === 'out of memory') return engineLimit(error) if (!(error instanceof Error)) throw error const thrown = `${error.name}: ${error.message}` 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('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, []) + return engineLimit(error.message) } } + +function engineLimit(thrown: string): Result { + return failure('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${thrown})`, []) +} diff --git a/src/markdown/parse/markdown-to-adf.ts b/src/markdown/parse/markdown-to-adf.ts index a7a6bdf..00fbe47 100644 --- a/src/markdown/parse/markdown-to-adf.ts +++ b/src/markdown/parse/markdown-to-adf.ts @@ -263,9 +263,9 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr const built = directiveBody(read.value, block.blocks, reading, path, depth) if (!built.ok) return built // Refused here so V8's throw past the bound is placed at this directive, like the bound check on other engines. - const readable = withinEngineLimits(() => commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }) ?? success(undefined)) + const readable = withinEngineLimits(() => commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })) if (!readable.ok) return readable - if (readable.value === undefined) return built + if (!readable.value) return built return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path) } diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 78043f7..e5ed0fb 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -131,9 +131,9 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res for (let next = pending.pop(); next !== undefined; next = pending.pop()) { const block = numberedPastMarkers(next.block) const listed = block.type === 'bulletList' || block.type === 'orderedList' - const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : undefined - if (spelled !== undefined && !spelled.ok) return spelled - if (!listed || spelled !== undefined) { + const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : success(!listed) + if (!spelled.ok) return spelled + if (spelled.value) { sequence.push(block) continue }