mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
Answer architecture round 6: each block is held to the bound as it is built, the reader's ask takes the engine guard, and the chunk ships under a worse comprehension ruling by the maintainer's call, with item 110 to fill the memo in one place
This commit is contained in:
@@ -445,7 +445,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, or writes as a directive a node whose CommonMark spelling would be longer than 536,870,888 characters. 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 536,870,888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds | 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 as a bug. Past 536,870,888 characters or the engine's limit, set the document aside: keep the ADF and pass it over, or show it 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, or writes as a directive a node whose CommonMark spelling would be longer than 536,870,888 characters. 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 536,870,888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds | 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 as a bug. Past 536,870,888 characters or the engine's limit, set the document aside: keep the ADF and pass it over, or show it read-only |
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
|
|||||||
@@ -627,3 +627,56 @@ Inherited architect, decided by:
|
|||||||
- Locality, Shape: `src/markdown/emit/block-directive-spelling.ts:27` `spellBlockDirectiveOpener`: the opener's layout is known here and in `spellDirectiveOpener`
|
- Locality, Shape: `src/markdown/emit/block-directive-spelling.ts:27` `spellBlockDirectiveOpener`: the opener's layout is known here and in `spellDirectiveOpener`
|
||||||
- Shape: `src/markdown/portable/adf-to-portable-markdown.ts:231` `reduceCodeBlock`: an ADF-to-ADF reducer carries a markdown length bound through a callback
|
- Shape: `src/markdown/portable/adf-to-portable-markdown.ts:231` `reduceCodeBlock`: an ADF-to-ADF reducer carries a markdown length bound through a callback
|
||||||
- Navigation: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long refusal leads straight to one small, well-named module
|
- Navigation: `src/markdown/length-bound.ts:41` `withinEngineLimits`: a too-long refusal leads straight to one small, well-named module
|
||||||
|
|
||||||
|
## 2026-10-12T19:45:45Z, PR #36 at 1f4551a, against 7a27f6b
|
||||||
|
|
||||||
|
Ruling: worse
|
||||||
|
|
||||||
|
Fell: Locality. Under the floor of 7.0: Locality 5.13, so the same ruling counts as worse.
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | worse | better | same | same |
|
||||||
|
| Inherited architect | better | worse | better | same | same |
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 6.5 | 5 | 6 | 5.5 | 5.5 |
|
||||||
|
| Mid A | 7 | 5 | 6 | 5 | 5.5 |
|
||||||
|
| Maintainability senior | 7 | 5.5 | 6.5 | 6 | 6 |
|
||||||
|
| Inherited architect | 6.5 | 5 | 5.5 | 6.5 | 5.5 |
|
||||||
|
| Mean | 6.75 | 5.13 | 6.00 | 5.75 | 5.63 |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: the memo is filled here and read-and-deleted in `readableBlock`, a write-once, read-once protocol whose order is set in another module
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:52` `writeMarkdown`: one rule, output past `longestMarkdown` is refused, is checked in six places with different triggers
|
||||||
|
- Navigation, Shape: `src/markdown/length-bound.ts:1` `tooLong`: a too-long refusal leads to one small module holding the bound, its message and the engine guard
|
||||||
|
- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: a generator replaces a boolean-returning callback, and both readers become plain loops
|
||||||
|
- Shape, Locality: `src/markdown/portable/adf-to-portable-markdown.ts:128` `portableSequence`: a refused spelling is returned where it is produced, and each block carries its own path
|
||||||
|
- Shape: `src/markdown/emit/inline-line.ts:235` `emitLeaf`: one content-or-text check, and the path is built only to refuse
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: filling and consuming the memo split across an exported predicate and a private reader, a consume-once protocol among three callers
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:52` `writeMarkdown`: one bound enforced at five sites in four files, and a new builder has nothing nearby saying whether it needs a check
|
||||||
|
- Navigation, Shape: `src/markdown/length-bound.ts:1` `withinEngineLimits`: an output-too-long symptom leads straight to a module named for it
|
||||||
|
- Navigation: `src/markdown/parse/markdown-to-adf.ts:47` `readDocument`: the parse takes the same guard as both writers
|
||||||
|
- Shape: `src/canonical-json.ts:23` `canonicalJsonPieces`: plain loops replace a boolean-returning callback
|
||||||
|
- Shape: `src/markdown/emit/inline-line.ts:235` `emitLeaf`: both leaf emitters return a plain emission, the path built only on refusal
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: a mutable memo shared by three walks, deleted on read and rebased by depth
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:125` `readableBlock`: shared mutable state with no single owner, parse and emit coupled both ways
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:114` `commonMarkSpelling`: the parser and the portable reducer call into the emitter and share a reference-keyed, delete-on-read memo
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Locality: `src/markdown/emit/adf-to-markdown.ts:126` `readableBlock`: the memo is mutated and deleted across parse, emit and portable, and the length bound is applied at five sites
|
||||||
|
|||||||
+26
-21
@@ -604,17 +604,22 @@ A four-seat comprehension panel scored the project under the floor of 7. Its sco
|
|||||||
| Mean | 6.88 | 5.63 | 6.25 | 5.63 | 6.00 |
|
| Mean | 6.88 | 5.63 | 6.25 | 5.63 | 6.00 |
|
||||||
|
|
||||||
Panels on near-identical code scored overall means of 5.75 to 6.00, and a seat moves ±0.5 between
|
Panels on near-identical code scored overall means of 5.75 to 6.00, and a seat moves ±0.5 between
|
||||||
runs. The maintainer shipped under a fall twice after three rounds: the move to GitHub,
|
runs. The maintainer shipped under a fall twice after three rounds: the move to GitHub, 2026-10-04,
|
||||||
2026-10-04, whose chunk changed only comments in `src/`; and item 78, 2026-10-07, whose three runs
|
whose chunk changed only comments in `src/`; and item 78, 2026-10-07, whose three runs fell below
|
||||||
fell below the run before it on Navigation, Locality and Shape while the overall held. Item 94,
|
the run before it on Navigation, Locality and Shape while the overall held. Item 94, 2026-10-10,
|
||||||
2026-10-10, also shipped after three rounds: a definitions-only paragraph leaves no block, so ending
|
also shipped after three rounds: a definitions-only paragraph leaves no block, so ending the list it
|
||||||
the list it follows takes state carried across lines, and every design read worse on Locality while
|
follows takes state carried across lines, and every design read worse on Locality while the overall
|
||||||
the overall held; the scores at 6182ba6 were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean
|
held; the scores at 6182ba6 were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5, mean 5.63. Item
|
||||||
5.63. Item 56, still open, is to give that state one owner. Items 85 and 86, 2026-10-11, shipped
|
56, still open, is to give that state one owner. Items 85 and 86, 2026-10-11, shipped after three
|
||||||
after three rounds under a fall the seats laid on item 93's `splitPath` and `splitPlace`, which
|
rounds under a fall the seats laid on item 93's `splitPath` and `splitPlace`, which place a refused
|
||||||
place a refused block's path by replaying `splitTaskList` on stand-in nodes; both seats read the
|
block's path by replaying `splitTaskList` on stand-in nodes; both seats read the chunk's own image
|
||||||
chunk's own image builder as a gain. The scores at be23835 were Junior 6.5, Mid 6, Senior 6 and
|
builder as a gain. The scores at be23835 were Junior 6.5, Mid 6, Senior 6 and Architect 6, mean
|
||||||
Architect 6, mean 6.13. `todo.md` item 102 is to place that path without the replay.
|
6.13. `todo.md` item 102 is to place that path without the replay. Item 106, 2026-10-13, shipped
|
||||||
|
after three rounds under a worse ruling, the maintainer's call: both paired seats read the overall
|
||||||
|
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 scores
|
||||||
|
at 1f4551a were Junior 5.5, Mid 5.5, Senior 6 and Architect 5.5. `todo.md` item 110 is to fill and
|
||||||
|
read the memo in one place.
|
||||||
|
|
||||||
## Properties on a fixed seed
|
## Properties on a fixed seed
|
||||||
|
|
||||||
@@ -669,17 +674,17 @@ Every writer, and the reader's CommonMark ask, refuses markdown longer than 2^29
|
|||||||
converts alike everywhere. A writer first refuses a document whose text alone passes the bound: the
|
converts alike everywhere. A writer first refuses a document whose text alone passes the bound: the
|
||||||
lossless writer spells every character of it, and the portable reduction refuses on the caller's
|
lossless writer spells every character of it, and the portable reduction refuses on the caller's
|
||||||
text, edge whitespace and all, since merging that text first could build past the bound. The
|
text, edge whitespace and all, since merging that text first could build past the bound. The
|
||||||
builders join as they always did, and the writer's result and the CommonMark ask's spelling are held
|
builders join plain strings, and each block, the writer's result and the CommonMark ask's spelling
|
||||||
to the bound once built. V8 stops at the bound itself and SpiderMonkey at its own longest string,
|
are held to the bound as they are built. V8 throws building the same string past the bound,
|
||||||
and `withinEngineLimits` refuses both throws as the same `tooLong`, so every engine answers alike.
|
SpiderMonkey past its own longest string, and `withinEngineLimits` refuses both throws as the same
|
||||||
The carry's two-space JSON, which indents every value its depth over, stops at the bound unbuilt
|
`tooLong`, so every engine stops at the same block with the same answer. The carry's two-space JSON,
|
||||||
(`joinedWithin`). A deep list or quote re-indents its whole text at each level before the bound
|
which indents every value its depth over, stops at the bound unbuilt (`joinedWithin`). A deep list
|
||||||
refuses it (`todo.md` item 109).
|
or quote re-indents its whole text at each level before the bound refuses it (`todo.md` item 109).
|
||||||
|
|
||||||
JavaScriptCore builds up to its own longest string, 2^31 − 1, before the check. A throw past that,
|
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. A
|
||||||
past any engine's longest array, or JavaScriptCore's `Out of memory`, which also reports a heap
|
throw past that, past any engine's longest array, or JavaScriptCore's `Out of memory`, which also
|
||||||
running short, is refused with the engine's message. `todo.md` item 108 bounds the line's own
|
reports a heap running short, is refused with the engine's message. `todo.md` item 108 bounds the
|
||||||
memory.
|
line's own memory.
|
||||||
|
|
||||||
## Nothing recurses unbounded
|
## Nothing recurses unbounded
|
||||||
|
|
||||||
|
|||||||
@@ -64,23 +64,6 @@ test('refuses lines their quotes would prefix past V8\'s longest string', () =>
|
|||||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong)
|
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [code, code], type: 'blockquote' }))), tooLong)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a directive whose opener and body together would pass V8\'s longest string', () => {
|
|
||||||
const long = 'a'.repeat(2 ** 28)
|
|
||||||
const expand: AdfNode = { attrs: { title: long }, content: [{ content: [{ text: long, type: 'text' }], type: 'codeBlock' }], type: 'expand' }
|
|
||||||
assert.equal(markdown(adfToLosslessMarkdown(document(expand))), tooLong)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses a list whose item markers would carry its lines past V8\'s longest string', () => {
|
|
||||||
const item: AdfNode = { content: [nearlyHalfTheBound()], type: 'listItem' }
|
|
||||||
assert.equal(markdown(adfToLosslessMarkdown(document({ content: [item, item], type: 'bulletList' }))), tooLong)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses a directive opener whose attributes together would pass V8\'s longest string', () => {
|
|
||||||
const long = 'a'.repeat(2 ** 28)
|
|
||||||
const panel: AdfNode = { attrs: { panelColor: long, panelIcon: long, panelIconText: long, panelType: 'custom' }, content: [paragraph({ text: 'x', type: 'text' })], type: 'panel' }
|
|
||||||
assert.equal(markdown(adfToLosslessMarkdown(document(panel))), tooLong)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses text past V8\'s longest string', () => {
|
test('refuses text past V8\'s longest string', () => {
|
||||||
const long = 'a'.repeat(2 ** 28)
|
const long = 'a'.repeat(2 ** 28)
|
||||||
const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' })))
|
const result = adfToLosslessMarkdown(document(paragraph({ text: long, type: 'text' }, { text: long, type: 'text' }, { text: long, type: 'text' })))
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ type PlacedBlocks = { blocks: readonly PlacedBlock[]; headroom: number }
|
|||||||
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
|
// CommonMark starts a new list where the marker changes, so every second list in a run of one kind takes `alternateText`.
|
||||||
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
|
type SpelledBlock = { spelling: 'commonmark' | 'directive'; text: string } | { alternateText: string; spelling: 'list'; text: string }
|
||||||
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
|
// Keyed by reference: only a caller building one object per position (the parse, the portable reduction) passes one; a consumer's document may share a node.
|
||||||
// Holds the spelling of each node the CommonMark ask was put to, until the walk above reads it.
|
|
||||||
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
export type SpellingMemo = Map<AdfNode, KeptSpelling>
|
||||||
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
|
type WalkedItem = { node: AdfNode; walk: PlacedBlocks }
|
||||||
export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined }
|
export type Writing = { flavour: WrittenFlavour; memo: SpellingMemo | undefined }
|
||||||
@@ -63,6 +62,9 @@ function walkBlocks(nodes: readonly AdfNode[], path: ConvertErrorPath, depth: nu
|
|||||||
for (const [index, node] of nodes.entries()) {
|
for (const [index, node] of nodes.entries()) {
|
||||||
const block = emitBlock(node, [...path, 'content', index], depth, writing)
|
const block = emitBlock(node, [...path, 'content', index], depth, writing)
|
||||||
if (!block.ok) return block
|
if (!block.ok) return block
|
||||||
|
// V8 throws building a block past the bound; an engine holding longer strings stops at the same block.
|
||||||
|
const fits = withinLongest(block.value.text)
|
||||||
|
if (!fits.ok) return fits
|
||||||
headroom = Math.min(headroom, block.value.headroom)
|
headroom = Math.min(headroom, block.value.headroom)
|
||||||
blocks.push({ ...block.value, node })
|
blocks.push({ ...block.value, node })
|
||||||
}
|
}
|
||||||
@@ -113,19 +115,19 @@ function emitBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing
|
|||||||
|
|
||||||
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<null> | undefined {
|
export function commonMarkSpelling(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing & { memo: SpellingMemo }): Result<null> | undefined {
|
||||||
const readable = readableBlock(node, path, depth, writing)
|
const readable = readableBlock(node, path, depth, writing)
|
||||||
if (readable === undefined) writing.memo.set(node, { block: undefined, depth })
|
if (readable === undefined) {
|
||||||
else if (readable.ok) writing.memo.set(node, { block: readable.value, depth })
|
writing.memo.set(node, { block: undefined, depth })
|
||||||
if (readable === undefined) return undefined
|
return undefined
|
||||||
|
}
|
||||||
if (!readable.ok) return readable
|
if (!readable.ok) return readable
|
||||||
// V8 throws past the bound while building the spelling; an engine holding longer strings refuses here alike.
|
const fits = withinLongest(readable.value.text)
|
||||||
if (readable.value.text.length > longestMarkdown) return tooLong()
|
if (!fits.ok) return fits
|
||||||
|
writing.memo.set(node, { block: readable.value, depth })
|
||||||
return readable.value.spelling === 'directive' ? undefined : success(null)
|
return readable.value.spelling === 'directive' ? undefined : success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
const kept = writing.memo?.get(node)
|
const kept = takeKeptSpelling(writing.memo, node)
|
||||||
// The walk reading an entry spells the node into its own text, so the entry is dropped: kept, every level of a deep subtree would hold its whole text.
|
|
||||||
writing.memo?.delete(node)
|
|
||||||
if (kept !== undefined) {
|
if (kept !== undefined) {
|
||||||
if (kept.block === undefined) return undefined
|
if (kept.block === undefined) return undefined
|
||||||
// An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards.
|
// An entry holds the spelling at its fill depth. A read at or above it rebases headroom; a read below re-spells, because a hit skips the walk's depth guards.
|
||||||
@@ -134,6 +136,13 @@ function readableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, wri
|
|||||||
return spellReadableBlock(node, path, depth, writing)
|
return spellReadableBlock(node, path, depth, writing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The CommonMark ask fills an entry; the walk above reads it once and spells it into its own text, so kept, every level of a deep subtree would hold its whole text.
|
||||||
|
function takeKeptSpelling(memo: SpellingMemo | undefined, node: AdfNode): KeptSpelling | undefined {
|
||||||
|
const kept = memo?.get(node)
|
||||||
|
memo?.delete(node)
|
||||||
|
return kept
|
||||||
|
}
|
||||||
|
|
||||||
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
function spellReadableBlock(node: AdfNode, path: ConvertErrorPath, depth: number, writing: Writing): Result<EmittedBlock> | undefined {
|
||||||
const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined
|
const portable = writing.flavour === 'portable' ? spellPortableBlock(node, path, depth, writing) : undefined
|
||||||
if (portable !== undefined) return portable
|
if (portable !== undefined) return portable
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { AdfNode } from '../adf/document.ts'
|
|||||||
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
import type { DirectiveSpan, Read } from './directive-syntax.ts'
|
||||||
import type { JsonSpelling } from '../canonical-json.ts'
|
import type { JsonSpelling } from '../canonical-json.ts'
|
||||||
import type { JsonValue } from '../json-value.ts'
|
import type { JsonValue } from '../json-value.ts'
|
||||||
import { joinedWithin } from './length-bound.ts'
|
|
||||||
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
|
import { canonicalJsonPieces, spellsCanonicalJson } from '../canonical-json.ts'
|
||||||
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
import { directiveEscape, malformedDirective, readSoleStringAttribute, spellAttributes, spellDirectiveOpener, spellInlineLeafDirective, spellStringAttribute, unsupportedNodeShape } from './directive-syntax.ts'
|
||||||
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
import { failure, success, type ConvertErrorPath, type Result } from '../result.ts'
|
||||||
@@ -10,6 +9,7 @@ import { fencedCodeBlock } from './commonmark/backtick-runs.ts'
|
|||||||
import { infoStringCarries } from './commonmark/grammar.ts'
|
import { infoStringCarries } from './commonmark/grammar.ts'
|
||||||
import { isAdfNode } from '../adf/document.ts'
|
import { isAdfNode } from '../adf/document.ts'
|
||||||
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
import { isJsonValue, nestingDepth, overNested } from '../json-value.ts'
|
||||||
|
import { joinedWithin } from './length-bound.ts'
|
||||||
import { largestNesting } from '../nesting.ts'
|
import { largestNesting } from '../nesting.ts'
|
||||||
import { parseJsonText } from '../json-text.ts'
|
import { parseJsonText } from '../json-text.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -262,9 +262,10 @@ function directiveNode(block: DirectiveBlock, reading: Reading, path: ConvertErr
|
|||||||
if (!read.ok) return read
|
if (!read.ok) return read
|
||||||
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
|
||||||
const readable = commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo })
|
// Guarded here, so V8's throw past the bound refuses at the directive as another engine's check does.
|
||||||
if (readable === undefined) return built
|
const readable = withinEngineLimits<null | undefined>(() => commonMarkSpelling(built.value, path, depth, { flavour: 'lossless', memo: reading.memo }) ?? success(undefined))
|
||||||
if (!readable.ok) return readable
|
if (!readable.ok) return readable
|
||||||
|
if (readable.value === undefined) 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,6 @@ test('refuses a paragraph whose text would pass V8\'s longest string, joining no
|
|||||||
assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /')
|
assert.equal(portableDocument(document(merged)), 'unsupported-node-shape at /')
|
||||||
})
|
})
|
||||||
|
|
||||||
// Two code blocks of 300 lines, some 1000 characters short of V8's longest string, so only the quote's prefixes pass it.
|
|
||||||
test('refuses an expand whose quoted lines would pass V8\'s longest string', () => {
|
|
||||||
const code: AdfNode = { content: [text(`${'a'.repeat(894_782)}\n`.repeat(300))], type: 'codeBlock' }
|
|
||||||
assert.equal(portableDocument(document(node('expand', { title: 'x' }, code, code))), 'unsupported-node-shape at /')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
test('refuses nesting past 500 levels wherever the reduction walks', () => {
|
||||||
const lowest = (bottom: AdfNode): string => {
|
const lowest = (bottom: AdfNode): string => {
|
||||||
let deep = bottom
|
let deep = bottom
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
import type { AdfDocument, AdfNode } from '../../adf/document.ts'
|
||||||
import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
import { adfDocumentFault, heldTextLength, nodeAttrs, nodeContent, nodeMarks } from '../../adf/document.ts'
|
||||||
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
|
import { blockNodeModel, isTaskItem } from '../../adf/block-nodes.ts'
|
||||||
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
|
|
||||||
import { centeredImage, externalMedia } from '../external-image.ts'
|
import { centeredImage, externalMedia } from '../external-image.ts'
|
||||||
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
import { commonMarkSpelling, largestListMarker, writeMarkdown, type SpellingMemo } from '../emit/adf-to-markdown.ts'
|
||||||
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
import { failure, faulted, success, type ConvertErrorPath, type Result } from '../../result.ts'
|
||||||
@@ -11,6 +10,7 @@ import { inlineNodeModel } from '../../adf/inline-nodes.ts'
|
|||||||
import { languageSlot } from '../code-language.ts'
|
import { languageSlot } from '../code-language.ts'
|
||||||
import { largestNesting } from '../../nesting.ts'
|
import { largestNesting } from '../../nesting.ts'
|
||||||
import { linkDefinition } from '../mark-spellings.ts'
|
import { linkDefinition } from '../mark-spellings.ts'
|
||||||
|
import { longestMarkdown, tooLong, withinEngineLimits } from '../length-bound.ts'
|
||||||
import { splitTaskList } from './task-list.ts'
|
import { splitTaskList } from './task-list.ts'
|
||||||
import { toEditorNormal } from './editor-normal.ts'
|
import { toEditorNormal } from './editor-normal.ts'
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
`Bar = 9`
|
`Bar = 9`
|
||||||
|
|
||||||
`Next ID = 110`
|
`Next ID = 111`
|
||||||
|
|
||||||
| Goal | W |
|
| Goal | W |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
|
| 89 | 0.2.1 | | **Build a `date`, `emoji`, `inlineCard`, `mention` or `status` holding no mark Atlassian's schema withholds from it, in every reader.** | 4 | 4 | 5 | 6 | 4 | 13.0 |
|
||||||
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
|
| 99 | 0.2.1 | defect | **Write a linked external image standing inline or in a table cell in the spelling a reader panel picks, in `adfToPortableMarkdown`.** | 3 | 3 | 6 | 7 | 7 | 12.9 |
|
||||||
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
|
| 100 | 0.2.1 | decision | **Put the reading of an image's title as its caption back to a reader panel, now that a writer panel reads a title as hover text.** | 3 | 3 | 4 | 5 | 4, 6 | 10.9 |
|
||||||
|
| 110 | 0.2.1 | decision | **Fill and read the spelling memo in `readableBlock` alone, dropping a spelled node's children's entries so memory stays linear.** | 3 | 3 | 2 | 6 | 2 | 10.9 |
|
||||||
| 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
|
| 60 | 0.2.1 | decision | **Collect the questions `parse/` asks `emit/` into one named module.** | 3 | 4 | 2 | 6 | 2 | 10.7 |
|
||||||
| 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
| 59 | 0.2.1 | decision | **Group the directive grammar into `src/markdown/directive/`, move `Read<T>` to `result.ts`, and move `Flavour` and `flavourClaims` to `markdown/flavour.ts`.** | 3 | 5 | 2 | 6 | 2 | 10.4 |
|
||||||
| 62 | 0.2.1 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
| 62 | 0.2.1 | decision | **Move the portable flavour's reading out of `parse/` and its writing out of `emit/` into `markdown/portable/`, so `emit/` no longer imports `portable/`.** | 4 | 5 | 2 | 6 | 2 | 9.4 |
|
||||||
@@ -81,7 +82,9 @@ The line escaper keeps an array element per character twice (`escapings`, `place
|
|||||||
50 bytes a character: one 20-million-character text node took 1.2 GB on Node 24, and one of 80
|
50 bytes a character: one 20-million-character text node took 1.2 GB on Node 24, and one of 80
|
||||||
million exhausted its 2.2 GB default heap. Twenty million `*` throw `RangeError: Set maximum size
|
million exhausted its 2.2 GB default heap. Twenty million `*` throw `RangeError: Set maximum size
|
||||||
exceeded`, which `withinEngineLimits` lets through. `losslessMarkdownToAdf` reaches both through a
|
exceeded`, which `withinEngineLimits` lets through. `losslessMarkdownToAdf` reaches both through a
|
||||||
directive's CommonMark ask. Found while shipping item 106, 2026-10-11.
|
directive's CommonMark ask, and reading 537 MB of markdown, a `!adf:bulletList` of two code blocks
|
||||||
|
just under the length bound, took 3.4 GB on Bun, too much for the gate to test its length refusal:
|
||||||
|
add that test with this fix. Found while shipping item 106, 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.
|
||||||
|
|
||||||
@@ -135,6 +138,15 @@ Every reader reads `` as an image captioned `t` (`docs/decisions.md`
|
|||||||
image only alone in its paragraph, 3 of 3 on 2026-10-09). On 2026-10-11 a writer panel of the same
|
image only alone in its paragraph, 3 of 3 on 2026-10-09). On 2026-10-11 a writer panel of the same
|
||||||
audience kept the captioned image's `!adf:caption` directive, 3 of 3, reading a title as hover text.
|
audience kept the captioned image's `!adf:caption` directive, 3 of 3, reading a title as hover text.
|
||||||
|
|
||||||
|
### 110. Fill and read the spelling memo in `readableBlock` alone, dropping a spelled node's children's entries so memory stays linear.
|
||||||
|
|
||||||
|
Item 106 made the CommonMark ask fill the memo and the walk above drop each entry as it reads it, so
|
||||||
|
a deep subtree holds no level's text twice. Every comprehension seat read that split, across
|
||||||
|
`commonMarkSpelling`, `readableBlock` and their three callers, as order-dependent state, and the
|
||||||
|
chunk shipped under a worse ruling on Locality. Keep main's single reader and writer instead: when
|
||||||
|
`readableBlock` keeps a node's spelling, it drops its children's and grandchildren's entries, whose
|
||||||
|
text the node's now holds. Found by the comprehension check of item 106, 2026-10-13.
|
||||||
|
|
||||||
### 60. Collect the questions `parse/` asks `emit/` into one named module.
|
### 60. Collect the questions `parse/` asks `emit/` into one named module.
|
||||||
|
|
||||||
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
|
`parse/` asks `emit/` through `commonMarkSpelling` and `openingLinkTakesDirective`, each imported
|
||||||
@@ -212,10 +224,9 @@ the reason the project read worse.
|
|||||||
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the
|
Each level of a nested list or quote re-indents its whole text, so the work is the depth times the
|
||||||
text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside
|
text. `losslessMarkdownToAdf` on 3 MB of lazy lines in a list nested 490 levels inside
|
||||||
`!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a
|
`!adf:blockquote` spends about 5 seconds re-spelling it. The same 2 MB split over two items of a
|
||||||
list nested 480 levels took 11 minutes to refuse on Node 24. A deep subtree past the length bound
|
list nested 480 levels took 11 minutes to refuse on Node 24: a deep subtree past the length bound
|
||||||
refuses only once every level below has re-indented its text, so no test reaches
|
refuses only once every level below has re-indented its text. The writers do the same on such a
|
||||||
`losslessMarkdownToAdf`'s length refusal in seconds: add one with this fix. The writers do the same
|
document. Found while shipping item 106, 2026-10-11.
|
||||||
on such a document. Found while shipping item 106, 2026-10-11.
|
|
||||||
|
|
||||||
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
|
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and markdown to and from HTML composed through ADF, each call named by its flavour.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user