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

Answer architecture round 2: 1 🔴, 1 🟡; the CommonMark ask answers yes or no, the memo's callers name themselves, the bound's policy reads at its constant, Firefox's bare out-of-memory string is engine-limit, and the floor entry no longer points at item 110

This commit is contained in:
2026-10-13 17:37:35 +02:00
parent 3422a51e0f
commit f1ea9fde3c
8 changed files with 61 additions and 17 deletions
+1 -1
View File
@@ -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` | | `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)` | | 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` | | 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 |
+31
View File
@@ -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 - 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 - 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 - 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
+1 -1
View File
@@ -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 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 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. 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 ## Properties on a fixed seed
+9 -8
View File
@@ -108,22 +108,23 @@ function interruptsParagraph(node: AdfNode): boolean {
function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> { function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> {
const model = blockNodeModel(node.type) const model = blockNodeModel(node.type)
if (model === undefined) return carriedFence(carriedBlock(node, path, depth)) 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 if (readable !== undefined) return readable
return emitDirectiveBlock(node, model, path, depth, () => walkBlocks(nodeContent(node), path, depth + 1, writing)) 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<null> | undefined { // Whether the node takes a CommonMark spelling, rather than its directive.
const readable = readableBlock(node, path, depth, writing, true) export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<boolean> {
if (readable === undefined) return undefined const readable = readableBlock(node, path, depth, writing, 'asked')
if (readable === undefined) return success(false)
if (!readable.ok) return readable if (!readable.ok) return readable
const fits = withinLongest(readable.value.text) const fits = withinLongest(readable.value.text)
if (!fits.ok) return fits 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. // 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<EmittedBlock> | undefined { function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing, caller: 'asked' | 'walked'): Result<EmittedBlock> | undefined {
const { memo } = writing const { memo } = writing
const kept = memo?.get(node) const kept = memo?.get(node)
memo?.delete(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 }) if (depth <= kept.depth) return success({ ...kept.block, headroom: kept.block.headroom + kept.depth - depth })
} }
const spelled = spellReadableBlock(node, path, depth, writing) const spelled = spellReadableBlock(node, path, depth, writing)
if (asked && spelled === undefined) memo?.set(node, { block: undefined, depth }) if (caller === '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?.ok === true) memo?.set(node, { block: spelled.value, depth })
return spelled return spelled
} }
+6
View File
@@ -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('Map maximum size exceeded'), 'engine-limit')
assert.equal(code('Set maximum size exceeded'), 'engine-limit') assert.equal(code('Set maximum size exceeded'), 'engine-limit')
assert.equal(code('invalid array length'), '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( assert.deepEqual(
withinEngineLimits(() => success('x')), withinEngineLimits(() => success('x')),
{ ok: true, value: 'x' }, { ok: true, value: 'x' },
+8 -2
View File
@@ -1,6 +1,6 @@
import { failure, success, type Result } from '../result.ts' 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 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. // 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<T>(convert: () => Result<T>): Result<T> {
try { try {
return convert() return convert()
} catch (error) { } 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 if (!(error instanceof Error)) throw error
const thrown = `${error.name}: ${error.message}` const thrown = `${error.name}: ${error.message}`
if (pastLongestString.includes(thrown)) return tooLong() if (pastLongestString.includes(thrown)) return tooLong()
// A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw. // A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw.
if (!pastEngineLimit.includes(thrown)) throw error 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<T>(thrown: string): Result<T> {
return failure('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${thrown})`, [])
}
+2 -2
View File
@@ -263,9 +263,9 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
const built = directiveBody(read.value, block.blocks, reading, path, depth) const built = directiveBody(read.value, block.blocks, reading, path, depth)
if (!built.ok) return built 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. // Refused here so V8's throw past the bound is placed at this directive, like the bound check on other engines.
const readable = withinEngineLimits<null | undefined>(() => 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.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) return failure('unsupported-node-shape', `${built.value.type} takes the CommonMark spelling, not the directive form`, path)
} }
@@ -131,9 +131,9 @@ function portableSequence(blocks: readonly Sourced[], reduction: Reduction): Res
for (let next = pending.pop(); next !== undefined; next = pending.pop()) { for (let next = pending.pop(); next !== undefined; next = pending.pop()) {
const block = numberedPastMarkers(next.block) const block = numberedPastMarkers(next.block)
const listed = block.type === 'bulletList' || block.type === 'orderedList' 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 const spelled = listed && block === next.block ? commonMarkSpelling(block, next.path, reduction.depth, { flavour: 'portable', memo: reduction.memo }) : success(!listed)
if (spelled !== undefined && !spelled.ok) return spelled if (!spelled.ok) return spelled
if (!listed || spelled !== undefined) { if (spelled.value) {
sequence.push(block) sequence.push(block)
continue continue
} }