diff --git a/AGENTS.md b/AGENTS.md index 9719b3f..fce1059 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,7 +73,7 @@ In `docs/decisions.md`: - HTML writes a URL as the ADF holds it, whatever its scheme - Nothing recurses unbounded - JSON text is read by `parseJsonText` -- No value `JSON.parse` or `structuredClone` builds loops a call +- A call reads its input once into a plain copy - Nothing spreads an unbounded array - A retry loop checks its own termination - After 32 passes, a line carries or drops every em, strike and strong mark @@ -135,7 +135,7 @@ parenthesized value set reading `string`; fenced examples are skipped. Keep pros keyed on the name a line introduces: an import sorts on its first binding, type imports ahead of value imports, so moving or renaming a module reorders nothing. - No casts: `as`, `as unknown as`, non-null `!`. A boundary owes a type guard validating the - fields it claims (`isAdfDocument`); past it everything is typed. Make invalid states + fields it claims (`readAdfDocument`); past it everything is typed. Make invalid states unrepresentable. - Failures are values: everything returns `Result`, nothing throws. `try/catch` only wrapped tightly around a call that genuinely throws, converted to a result on the spot. A reader with no diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d1b945..fab32ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased + +- **Breaking:** `readAdfDocument` replaces `isAdfDocument`: it returns a `Result` whose + error names the branch that refused, and `MIGRATION.md` shows the change. +- `readAdfDocument`, `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown` read the + document once into a plain copy, so a document holding an accessor property, or a Proxy that + throws, returns `not-an-adf-document` where it ran the getter, looped or threw. A document of + more than 10,000,000 JSON values returns `unsupported-node-shape`. + ## 0.3.1 - The README opens with a one-line description, a list of the calls and the package's perks, and diff --git a/MIGRATION.md b/MIGRATION.md index 03004bd..610e0a2 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,5 +1,24 @@ # Migrating +## From `0.3.1` to `0.4.0` + +`isAdfDocument` is gone. `readAdfDocument` takes its place and returns a `Result`; the +`message` of its error names the branch that refused. + +| `0.3.1` | `0.4.0` | +| --- | --- | +| `if (isAdfDocument(value)) convert(value)` | `const read = readAdfDocument(value); if (read.ok) convert(read.value)` | +| `const valid = isAdfDocument(value)` | `const valid = readAdfDocument(value).ok` | + +Convert `read.value`: it is a copy that shares no object with `value`. The copy keeps own +enumerable keys, as `JSON.stringify` does, so an inherited or non-enumerable key drops. A document +nesting past 500 levels still reads; the converters refuse it as `unsupported-nesting-depth`. + +`readAdfDocument`, `adfToHtml`, `adfToLosslessMarkdown` and `adfToPortableMarkdown` read a document +once into that copy. A document holding an accessor property, or a Proxy that throws, is +`not-an-adf-document` in `0.4.0`, where `0.3.1` ran the getter or threw. A document holding more +than 10,000,000 JSON values is `unsupported-node-shape`. + ## From `0.2.0` to `0.3.0` Every reader skips code text when it spells a mark other than a link, and `losslessMarkdownToAdf` diff --git a/README.md b/README.md index 1ff556b..952da89 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ sets example documents beside each writer's output and says whether that output ```ts adfToLosslessMarkdown(doc: AdfDocument): Result losslessMarkdownToAdf(markdown: string): Result -isAdfDocument(v: unknown): v is AdfDocument +readAdfDocument(v: unknown): Result commonMarkToAdf(markdown: string): Result @@ -333,12 +333,15 @@ Each call's name says which format it reads or writes: | `htmlToAdf` | reads the HTML `adfToHtml` writes, also as a browser's `innerHTML` or a form post spells it, a carry element from its `data-adf`; refuses any other HTML, an edit to what a carry element shows included | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | | `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` | | `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` | +| `readAdfDocument` | reads ADF as JSON, a value you hold from a parser or a store, into an `AdfDocument`, and writes nothing | `not-an-adf-document`, `unsupported-node-shape` | -`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the -converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding -only `attrs`, `content`, `marks`, `text` and `type`, attribute values that are JSON values, and no -object holding itself. It returns `true` for a document nesting past 500 levels; the converters -refuse that one with `unsupported-nesting-depth`. +`readAdfDocument` reads the JSON you receive into an `AdfDocument`, copying it once. It reads data +properties and own enumerable keys alone, as `JSON.stringify` does, so an accessor property or a +Proxy that throws is `not-an-adf-document` and never a throw, and the three writers read their +document the same way. It checks the shape the converters require: `type: 'doc'`, a finite +`version` (the converters take only 1), nodes holding only `attrs`, `content`, `marks`, `text` and +`type`, attribute values that are JSON values, and no object holding itself. It reads a document +nesting past 500 levels; the converters refuse that one with `unsupported-nesting-depth`. The types: @@ -612,7 +615,7 @@ Which reader raises which code: [The calls](#the-calls). Codes every direction r | Code | Fires when | What you can do | | --- | --- | --- | -| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself, an attribute value that is not a plain object, array or JSON primitive, such as a `Date` or `Map` | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused. Build an attribute value as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`, `{ ...instance }` | +| `not-an-adf-document` | the value handed in is no ADF document — a missing or wrong `type`, a stray key, a node that is not a node, an object in the document holding itself, an accessor property, a Proxy that throws, an attribute value that is not a plain object, array or JSON primitive, such as a `Date` or `Map` | read the boundary you receive JSON at with `readAdfDocument`; the message names the branch that refused. Build an attribute value as JSON holds it: `date.toISOString()`, `Object.fromEntries(map)`, `{ ...instance }` | | `unsupported-document-version` | the document's `version` is not 1 | keep the ADF and pass the document over, or show it read-only; the version is the site's, not yours to change | ### Either direction @@ -662,6 +665,9 @@ Every call can raise these: `path` and message name. - **Writing, in `adfToPortableMarkdown`**: a line or task list the portable writer cannot spell. This is a bug in this library: report it. +- **Reading a document, in `readAdfDocument` and the three writers**: the document holds more than + 10,000,000 JSON values, each object, array, string, number, boolean and null counting one. Keep + the ADF and pass the document over or show it read-only; where you built the ADF, split it. - **Past the engine's longest string**: - a writer's markdown or HTML would pass 536870888 characters, V8's longest string, on every engine; @@ -772,9 +778,9 @@ Contract: [`docs/decisions.md`](https://github.com/larvit/adf-codec/blob/main/do | `adfToPortableMarkdown` | function | ADF to portable markdown, lossy | | `commonMarkToAdf` | function | strict CommonMark to ADF | | `htmlToAdf` | function | `adfToHtml`'s HTML, read back to ADF | -| `isAdfDocument` | function | type guard for `AdfDocument` | | `losslessMarkdownToAdf` | function | the lossless flavour to ADF | | `portableMarkdownToAdf` | function | portable markdown to ADF | +| `readAdfDocument` | function | JSON you receive, checked and copied into an `AdfDocument` | ## Changing the source diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js index 32ecb32..dc8eeec 100644 --- a/browser-tests/convert-corpus.js +++ b/browser-tests/convert-corpus.js @@ -1,5 +1,5 @@ try { - const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js') + const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, losslessMarkdownToAdf, readAdfDocument } = await import('/dist/index.js') // WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it. const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result) // A viewer/editor app posts the page's innerHTML back, so htmlToAdf must read what the browser writes. @@ -18,11 +18,11 @@ try { const adf = JSON.parse(json) const emitted = adfToLosslessMarkdown(adf) const html = adfToHtml(adf) - return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, innerHtml: throughInnerHtml(adf), isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined } + return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, innerHtml: throughInnerHtml(adf), isDocument: readAdfDocument(adf).ok, name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined } }), roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => { const adf = JSON.parse(json) - return { emitted: adfToLosslessMarkdown(adf), isDocument: isAdfDocument(adf), name, parsed: spelled(losslessMarkdownToAdf(markdown)) } + return { emitted: adfToLosslessMarkdown(adf), isDocument: readAdfDocument(adf).ok, name, parsed: spelled(losslessMarkdownToAdf(markdown)) } }), }) } catch (cause) { diff --git a/docs/decisions.md b/docs/decisions.md index f7e35cf..2a51ad4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -963,13 +963,21 @@ can read an escaped key as another key it read before, and the round-trip then r output. A test fails on `JSON.parse(` in a shipped file. The tests' own oracles keep `JSON.parse`, since a misread there fails the gate and never hides a bug. -## No value `JSON.parse` or `structuredClone` builds loops a call +## A call reads its input once into a plain copy -2026-10-04, the maintainer. Goals 1 and 9. Valid until `todo.md` item 77 lands. +2026-10-04, the maintainer; the 10 million 2026-10-05; the code and the name 2026-11-09, applying +§Which code a cause takes and the entry for the long output. Goals 1 and 9. Valid while a persona +can hand in a value with accessors or a Proxy. -The guards walk a value by `Object.values` and refuse a cycle in it. An inherited or non-enumerable -key, a getter, a Proxy or a custom iterator is the caller's to rule out: no persona builds one. Goal -9's size counts ADF as JSON, so a shared object costs once per place it is held. +`readAdfDocument` and the three writers copy the input once, before any check, reading property +descriptors so no getter runs: an accessor property, or a Proxy trap that throws, is +`not-an-adf-document`. The copy keeps own enumerable string keys, as `JSON.stringify` does, so an +inherited or non-enumerable key drops, and reads an array by index, so no iterator runs. It refuses +a cycle by the objects above the one it copies. Every later step reads the copy. The copy takes at +most 10 million JSON values, each object, array, string, number, boolean and null counting one, +past which the document is `unsupported-node-shape`, since the document does not convert; an array +claiming more elements than the values left is refused before it is read. Goal 9's size counts ADF +as JSON, so a shared object costs once per place it is held. ## Nothing spreads an unbounded array diff --git a/package-tests/consumer.ts b/package-tests/consumer.ts index b2d5160..a6b1577 100644 --- a/package-tests/consumer.ts +++ b/package-tests/consumer.ts @@ -1,4 +1,4 @@ -import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, htmlToAdf, isAdfDocument, losslessMarkdownToAdf, portableMarkdownToAdf, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec' +import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown, commonMarkToAdf, htmlToAdf, losslessMarkdownToAdf, portableMarkdownToAdf, readAdfDocument, type AdfDocument, type ConvertErrorCode, type ParseError, type Result } from '@larvit/adf-codec' const document: AdfDocument = { content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } @@ -8,9 +8,9 @@ const parsed: Result = losslessMarkdownToAdf('x\n') const portableEmitted: Result = adfToPortableMarkdown(document) const portableParsed: Result = portableMarkdownToAdf('x\n') const commonMarkParsed: Result = commonMarkToAdf('x\n') -const guarded: boolean = isAdfDocument(document) +const read: Result = readAdfDocument(document) const htmlParsed: Result = htmlToAdf('

x

\n') const code: ConvertErrorCode | undefined = emitted.ok ? undefined : emitted.error.code const line: number | undefined = parsed.ok ? undefined : parsed.error.position.line -export const surface = { code, commonMarkParsed, guarded, htmlEmitted, htmlParsed, line, portableEmitted, portableParsed } +export const surface = { code, commonMarkParsed, htmlEmitted, htmlParsed, line, portableEmitted, portableParsed, read } diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 0c0843e..137797a 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -5,13 +5,23 @@ import vm from 'node:vm' import type { AdfDocument, AdfNode } from './document.ts' import type { JsonValue } from '../json-value.ts' import { adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown } from '../index.ts' -import { adfDocumentFault, isAdfDocument, writableContent } from './document.ts' +import { adfDocumentFault, readAdfDocument, writableContent } from './document.ts' import { largestNesting } from '../nesting.ts' +import { mostValues } from './plain-copy.ts' function fault(value: unknown): string { return adfDocumentFault(value)?.message ?? 'accepted' } +function readable(value: unknown): boolean { + return readAdfDocument(value).ok +} + +function readFault(value: unknown): string { + const result = readAdfDocument(value) + return result.ok ? 'accepted' : `${result.error.code}: ${result.error.message}` +} + function faultCode(value: unknown): string { return adfDocumentFault(value)?.code ?? 'accepted' } @@ -27,13 +37,13 @@ function withAttribute(value: JsonValue): unknown { } test('accepts an editor-normal document', () => { - assert.equal(isAdfDocument({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) - assert.equal(isAdfDocument({ type: 'doc', version: 1 }), true) + assert.equal(readable({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), true) + assert.equal(readable({ type: 'doc', version: 1 }), true) assert.equal(fault({ content: [{ content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted') }) test('names the check anything that is not a doc node failed', () => { - assert.equal(isAdfDocument(null), false) + assert.equal(readable(null), false) assert.equal(fault(null), 'an ADF document is an object: found null') assert.equal(fault(undefined), 'an ADF document is an object: found undefined') assert.equal(fault([]), 'an ADF document is an object: found an array') @@ -54,13 +64,13 @@ test('names the check anything that is not a doc node failed', () => { test('rejects a node whose shape ProseMirror JSON cannot hold', () => { assert.equal(fault({ content: {}, type: 'doc', version: 1 }), "an ADF document's content is an array: found an object") assert.equal(fault({ content: [{ type: 1 }], type: 'doc', version: 1 }), "an ADF document's content holds ADF nodes: one of them is not") - assert.equal(isAdfDocument({ content: [{ type: 1 }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ marks: [{ type: 1 }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ attrs: { a: Number.POSITIVE_INFINITY }, type: 'paragraph' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ type: 1 }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ text: 1, type: 'text' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ node: 'x', type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ content: {}, type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ marks: [{ type: 1 }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ attrs: { a: Number.POSITIVE_INFINITY }, type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ attrs: [], type: 'paragraph' }], type: 'doc', version: 1 }), false) }) test('names the attribute nesting past the levels the parser reads one at, and still calls the value a document', () => { @@ -68,48 +78,48 @@ test('names the attribute nesting past the levels the parser reads one at, and s assert.equal(fault(withAttribute(nested(largestNesting))), 'accepted') assert.equal(fault(withAttribute(nested(largestNesting + 1))), deeper('a', 'paragraph')) assert.equal(faultCode(withAttribute(nested(largestNesting + 1))), 'unsupported-nesting-depth') - assert.equal(isAdfDocument(withAttribute(nested(largestNesting + 1))), true) + assert.equal(readable(withAttribute(nested(largestNesting + 1))), true) const marked = (levels: number): unknown => ({ content: [{ marks: [{ attrs: { a: nested(levels) }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }) assert.equal(fault(marked(largestNesting)), 'accepted') assert.equal(fault(marked(largestNesting + 1)), deeper('a', 'link')) - assert.equal(isAdfDocument(marked(largestNesting + 1)), true) + assert.equal(readable(marked(largestNesting + 1)), true) }) test('accepts the JSON values an attribute may hold', () => { - assert.equal(isAdfDocument({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) - assert.equal(isAdfDocument({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ attrs: { a: Object.assign(Object.create(null), { b: 1 }) }, type: 'paragraph' }], type: 'doc', version: 1 }), true) - assert.equal(isAdfDocument(vm.runInNewContext("({ content: [{ attrs: { a: { b: [1] } }, type: 'paragraph' }], type: 'doc', version: 1 })")), true) + assert.equal(readable({ content: [{ attrs: { a: [1, 'x', null, true, { b: 2 }] }, type: 'paragraph' }], type: 'doc', version: 1 }), true) + assert.equal(readable({ content: [{ attrs: { a: [() => 1] }, type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ attrs: { a: Object.assign(Object.create(null), { b: 1 }) }, type: 'paragraph' }], type: 'doc', version: 1 }), true) + assert.equal(readable(vm.runInNewContext("({ content: [{ attrs: { a: { b: [1] } }, type: 'paragraph' }], type: 'doc', version: 1 })")), true) }) test('refuses an attribute value that is an object JSON does not hold', () => { class Point { x = 1 } for (const value of [new Date(0), new Map([['b', 1]]), new Set([1]), new Uint8Array([1, 2]), new Point(), [new Map()], { b: new Set() }]) { - assert.equal(isAdfDocument({ content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 }), false, String(value)) + assert.equal(readable({ content: [{ attrs: { a: value }, type: 'paragraph' }], type: 'doc', version: 1 }), false, String(value)) } - assert.equal(isAdfDocument({ content: [{ attrs: new Map(), type: 'paragraph' }], type: 'doc', version: 1 }), false) - assert.equal(isAdfDocument({ content: [{ marks: [{ attrs: { href: new URL('https://x.test') }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ attrs: new Map(), type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readable({ content: [{ marks: [{ attrs: { href: new URL('https://x.test') }, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), false) }) test("reads a node's siblings as a walk rather than as one call's arguments", () => { const wide = { content: [{ content: Array.from({ length: 200000 }, () => ({ type: 'rule' })), type: 'blockquote' }], type: 'doc', version: 1 } - assert.equal(isAdfDocument(wide), true) + assert.equal(readable(wide), true) assert.equal(fault(wide), 'accepted') }) test('refuses an object holding itself, and accepts one shared', () => { - const holdsItself = 'an ADF document is a tree: an object in it holds itself' + const holdsItself = 'not-an-adf-document: an ADF document is a tree: an object in it holds itself' const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' } paragraph.content.push(paragraph) - assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself) + assert.equal(readFault({ content: [paragraph], type: 'doc', version: 1 }), holdsItself) const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 } document.content.push(document) - assert.equal(fault(document), holdsItself) + assert.equal(readFault(document), holdsItself) const attrs: Record = {} attrs['a'] = [attrs] - assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself) - assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself) - assert.equal(isAdfDocument({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false) + assert.equal(readFault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself) + assert.equal(readFault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), holdsItself) + assert.equal(readable({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), false) const bold = { type: 'strong' } const text = { marks: [bold], text: 'x', type: 'text' } const shared = { content: [text, text], type: 'paragraph' } @@ -217,3 +227,97 @@ test('names a link-marked card at any depth by its path alone, with no step quad assert.equal(!refused.ok && refused.error.path.length, 2 * (depth + 1)) assert.deepEqual(!refused.ok && refused.error.path.slice(0, 4), ['content', 0, 'content', 0]) }) + +const writers = [adfToHtml, adfToLosslessMarkdown, adfToPortableMarkdown] + +function documentOf(attrs: unknown): unknown { + return { content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 } +} + +test('reads a document once into a copy that shares nothing with the original', () => { + const original = { content: [{ attrs: { a: [1, { b: 'x' }] }, content: [{ text: 'x', type: 'text' }], type: 'paragraph' }], type: 'doc', version: 1 } + const result = readAdfDocument(original) + assert.ok(result.ok) + assert.deepEqual(result.value, original) + assert.notEqual(result.value, original) + original.content[0]?.content.push({ text: 'y', type: 'text' }) + assert.equal(result.value.content?.[0]?.content?.length, 1) +}) + +test('refuses a value holding an accessor property, whichever object or array holds it', () => { + const refused = 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter' + assert.equal(readFault({ get content() { return [] }, type: 'doc', version: 1 }), refused) + assert.equal(readFault({ content: [{ get type() { return 'rule' } }], type: 'doc', version: 1 }), refused) + assert.equal(readFault(documentOf({ set a(value: number) { void value } })), refused) + assert.equal(readFault(documentOf({ a: Object.defineProperty([], 0, { enumerable: true, get: () => 1 }) })), refused) +}) + +test('refuses a getter minting a fresh child per access without walking it', () => { + const endless = (): unknown => ({ get next() { return endless() } }) + assert.equal(readFault(documentOf(endless())), 'not-an-adf-document: an ADF document is plain data: one of its objects holds a getter or a setter') +}) + +test('refuses a Proxy that throws, and a revoked one, wherever they stand', () => { + const unreadable = 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw' + const throwing = (target: object, trap: string): object => new Proxy(target, { [trap]: () => { throw new Error('trap') } }) + for (const trap of ['getOwnPropertyDescriptor', 'getPrototypeOf', 'ownKeys']) { + assert.equal(readFault(throwing({ type: 'doc', version: 1 }, trap)), unreadable, trap) + assert.equal(readFault(documentOf(throwing({ b: 1 }, trap))), unreadable, trap) + } + assert.equal(readFault({ content: throwing([1], 'getOwnPropertyDescriptor'), type: 'doc', version: 1 }), unreadable) + assert.equal(readFault({ content: throwing([], 'get'), type: 'doc', version: 1 }), unreadable) + const revoked = Proxy.revocable({}, {}) + revoked.revoke() + assert.equal(readFault(revoked.proxy), unreadable) + assert.equal(readFault(documentOf(revoked.proxy)), unreadable) +}) + +test('copies own enumerable string keys alone, as JSON.stringify does', () => { + const keyed = Object.defineProperty({ own: 2, [Symbol('symbol')]: 4 }, 'hidden', { enumerable: false, value: 3 }) + const result = readAdfDocument(documentOf(keyed)) + assert.ok(result.ok) + assert.deepEqual(result.value.content?.[0]?.attrs, { own: 2 }) + assert.equal(readable({ content: [Object.create({ type: 'rule' })], type: 'doc', version: 1 }), false) + const proxied = new Proxy({ b: 1 }, { getOwnPropertyDescriptor: () => undefined }) + const dropped = readAdfDocument(documentOf(proxied)) + assert.ok(dropped.ok) + assert.deepEqual(dropped.value.content?.[0]?.attrs, {}) +}) + +test('copies a key named __proto__ as a key, leaving the copy a plain object', () => { + const result = readAdfDocument(documentOf(JSON.parse('{"__proto__":{"polluted":1}}'))) + assert.ok(result.ok) + const attrs = result.value.content?.[0]?.attrs + assert.deepEqual(Object.keys(attrs ?? {}), ['__proto__']) + assert.equal(Object.getPrototypeOf(attrs), Object.prototype) + assert.equal(Reflect.get(attrs ?? {}, 'polluted'), undefined) +}) + +test('reads an array by index, so no iterator runs and a hole is no JSON value', () => { + const iterated = Object.assign([1, 2], { [Symbol.iterator]: () => { throw new Error('iterated') } }) + const result = readAdfDocument(documentOf({ a: iterated })) + assert.ok(result.ok) + assert.deepEqual(result.value.content?.[0]?.attrs, { a: [1, 2] }) + assert.equal(readable(documentOf({ a: [1, , 3] })), false) +}) + +test('refuses an array claiming more elements than the values left, before it reads any', () => { + const claimed = `unsupported-node-shape: an ADF document holds at most ${mostValues} JSON values: this one holds more` + const claiming = (length: unknown): object => new Proxy([], { get: (target, key) => (key === 'length' ? length : Reflect.get(target, key)) }) + assert.equal(readFault({ content: claiming(2 ** 32 - 1), type: 'doc', version: 1 }), claimed) + assert.equal(readFault({ content: new Array(mostValues - 1), type: 'doc', version: 1 }), claimed) + assert.equal(readFault({ content: claiming('many'), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw') + assert.equal(readFault({ content: claiming(-1), type: 'doc', version: 1 }), 'not-an-adf-document: an ADF document is plain data: reading one of its objects threw') +}) + +test('returns a result for a hostile document from every writer', () => { + const throwing = new Proxy({ type: 'rule' }, { ownKeys: () => { throw new Error('trap') } }) + const hostile: AdfDocument[] = [{ get content() { return [] }, type: 'doc', version: 1 }, { content: [throwing], type: 'doc', version: 1 }] + for (const write of writers) { + for (const document of hostile) { + const result = write(document) + assert.ok(!result.ok) + assert.equal(result.error.code, 'not-an-adf-document') + } + } +}) diff --git a/src/adf/document.ts b/src/adf/document.ts index 446cb41..a7ea4d1 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,8 +1,9 @@ import { blockNodeModel } from './block-nodes.ts' -import { failure, faulted, success, type ConvertError, type ConvertFault, type Result } from '../result.ts' +import { failure, faulted, success, type ConvertError, type ConvertFault, type Read, type Result } from '../result.ts' import { inlineNodeModel } from './inline-nodes.ts' import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' +import { plainCopy } from './plain-copy.ts' import { withinLongest } from '../length-bound.ts' import { serializeCanonicalJson } from '../canonical-json.ts' @@ -34,21 +35,34 @@ const markKeys = ['attrs', 'type'] const nodeKeys = ['attrs', 'content', 'marks', 'text', 'type'] export function adfDocumentFault(value: unknown): ConvertFault | undefined { + const shaped = shapedDocument(value) + return shaped.fault ?? nestingFault(shaped.value.content ?? []) +} + +// Reads `value` once into a plain copy and checks the copy's shape. Depth is the walks' business, not the shape's: a deep document reads as blocks and marks do. +export function readAdfDocument(value: unknown): Result { + const copy = plainCopy(value) + if (copy.fault !== undefined) return faulted(copy.fault, []) + const shaped = shapedDocument(copy.value) + return shaped.fault === undefined ? success(shaped.value) : faulted(shaped.fault, []) +} + +function shapedDocument(value: unknown): Read { if (!isRecord(value)) return notADocument(`an ADF document is an object: found ${describe(value)}`) const extra = extraKey(value, documentKeys) if (extra !== undefined) return notADocument(`an ADF document holds content, type and version alone: found the key ${extra}`) if (!('type' in value)) return notADocument('an ADF document holds type "doc": found no type field') - if (value['type'] !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(value['type'])}`) + const type: unknown = value['type'] + if (type !== 'doc') return notADocument(`an ADF document holds type "doc": found ${describe(type)}`) if (!('version' in value)) return notADocument('an ADF document holds a version number: found no version field') - const version = value['version'] + const version: unknown = value['version'] if (typeof version !== 'number' || !Number.isFinite(version)) return notADocument(`an ADF document holds a version number: found ${describe(version)}`) - if (!('content' in value)) return undefined - const held: unknown = value['content'] - if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`) - const content: readonly unknown[] = held - if (holdsItself(content)) return notADocument('an ADF document is a tree: an object in it holds itself') - if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not") - return nestingFault(content) + if (!('content' in value)) return { value: { type, version } } + const content: unknown = value['content'] + if (!Array.isArray(content)) return notADocument(`an ADF document's content is an array: found ${describe(content)}`) + const nodes: readonly unknown[] = content + if (!isNodeArray(nodes)) return notADocument("an ADF document's content holds ADF nodes: one of them is not") + return { value: { content: nodes, type, version } } } // The checks every writer opens with; `undefined` where the document holds no `content` key, which each format spells its own way. @@ -126,12 +140,6 @@ export function mergedTextRuns(nodes: readonly AdfNode[], joins: (previous: AdfN return merged } -// Depth is the walks' business, not the shape's: the guard waves a deep document through as blocks and marks do. -export function isAdfDocument(value: unknown): value is AdfDocument { - const fault = adfDocumentFault(value) - return fault === undefined || fault.code === 'unsupported-nesting-depth' -} - export function isAdfNode(value: unknown): value is AdfNode { return isNodeArray([value]) } @@ -184,24 +192,7 @@ function shapeRefusal(node: AdfNode): string | undefined { return model.contentModel === 'none' && holdsContent ? `a node of type ${node.type} holds no content: this one holds some` : undefined } -// Tracks ancestors alone: a tree may share an object, but an object holding itself walks the guards forever. -function holdsItself(root: object): boolean { - const pending = [{ depth: 0, item: root }] - const ancestors: object[] = [] - const onPath = new Set() - while (pending.length > 0) { - const entry = pending.pop() - if (entry === undefined) continue - while (ancestors.length > entry.depth) onPath.delete(ancestors.pop()) - if (onPath.has(entry.item)) return true - ancestors.push(entry.item) - onPath.add(entry.item) - for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child }) - } - return false -} - -function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { +function isNodeArray(value: readonly unknown[]): value is AdfNode[] { const pending: unknown[] = [...value] while (pending.length > 0) { const node = pending.pop() @@ -265,8 +256,8 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } -function notADocument(message: string): ConvertFault { - return { code: 'not-an-adf-document', message } +function notADocument(message: string): { fault: ConvertFault } { + return { fault: { code: 'not-an-adf-document', message } } } function describe(value: unknown): string { diff --git a/src/adf/plain-copy.ts b/src/adf/plain-copy.ts new file mode 100644 index 0000000..69fb2c3 --- /dev/null +++ b/src/adf/plain-copy.ts @@ -0,0 +1,80 @@ +import type { ConvertFault, Read } from '../result.ts' +import { isPlainObject } from '../json-value.ts' + +// Each object, array, string, number, boolean and null counts one: far past any real page, and it stops a Proxy claiming unbounded width. +export const mostValues = 10_000_000 + +// An object that is no plain object is copied with this prototype, so `isJsonValue` refuses the copy as it refuses the original. +const unplain = {} + +type Step = { depth: number; key: number | string; source: unknown; target: object } +type Held = { entries: [number | string, unknown][]; kind: 'array' | 'object' | 'unplain' } + +const holdsItself: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is a tree: an object in it holds itself' } +const tooMany: ConvertFault = { code: 'unsupported-node-shape', message: `an ADF document holds at most ${mostValues} JSON values: this one holds more` } +const unreadable: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: reading one of its objects threw' } +const withAccessor: ConvertFault = { code: 'not-an-adf-document', message: 'an ADF document is plain data: one of its objects holds a getter or a setter' } + +// Every later step reads the copy, never the caller's object: a getter, a Proxy trap or an iterator of theirs runs at most once here, and never an accessor. +export function plainCopy(value: unknown): Read { + const root: unknown[] = [] + const pending: Step[] = [{ depth: 0, key: 0, source: value, target: root }] + const ancestors: object[] = [] + const onPath = new Set() + let left = mostValues - 1 + for (let step = pending.pop(); step !== undefined; step = pending.pop()) { + const source = step.source + if (typeof source !== 'object' || source === null) { + place(step.target, step.key, source) + continue + } + // A pre-order walk: a step at depth d has the d objects above it as its ancestors, and the stack pops it before its later siblings. + while (ancestors.length > step.depth) onPath.delete(ancestors.pop()) + if (onPath.has(source)) return { fault: holdsItself } + ancestors.push(source) + onPath.add(source) + const read = heldBy(source, left) + if (read.fault !== undefined) return read + const { entries, kind } = read.value + left -= entries.length + const copy = kind === 'array' ? [] : kind === 'object' ? {} : Object.create(unplain) + place(step.target, step.key, copy) + for (const [key, held] of entries.reverse()) pending.push({ depth: step.depth + 1, key, source: held, target: copy }) + } + return { value: root[0] } +} + +// The only reads of the caller's object, so the only `try`: a throw there is the caller's Proxy. +function heldBy(source: object, left: number): Read { + try { + const entries: [number | string, unknown][] = [] + if (Array.isArray(source)) { + const length: unknown = source.length + if (typeof length !== 'number' || !Number.isSafeInteger(length) || length < 0) return { fault: unreadable } + if (length > left) return { fault: tooMany } + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(source, index) + if (descriptor !== undefined && !('value' in descriptor)) return { fault: withAccessor } + entries.push([index, descriptor?.value]) + } + return { value: { entries, kind: 'array' } } + } + const keys = Reflect.ownKeys(source) + if (keys.length > left) return { fault: tooMany } + for (const key of keys) { + if (typeof key !== 'string') continue + const descriptor = Object.getOwnPropertyDescriptor(source, key) + if (descriptor === undefined || descriptor.enumerable !== true) continue + if (!('value' in descriptor)) return { fault: withAccessor } + entries.push([key, descriptor.value]) + } + return { value: { entries, kind: isPlainObject(source) ? 'object' : 'unplain' } } + } catch { + return { fault: unreadable } + } +} + +function place(target: object, key: number | string, copy: unknown): void { + if (key === '__proto__') Object.defineProperty(target, key, { configurable: true, enumerable: true, value: copy, writable: true }) + else Reflect.set(target, key, copy) +} diff --git a/src/conformance/corpus.test.ts b/src/conformance/corpus.test.ts index 1dac46c..85528a0 100644 --- a/src/conformance/corpus.test.ts +++ b/src/conformance/corpus.test.ts @@ -4,12 +4,13 @@ import { fileURLToPath } from 'node:url' import { readFileSync, readdirSync } from 'node:fs' import test from 'node:test' +import type { AdfDocument } from '../adf/document.ts' import { adfToHtml } from '../html/emit/adf-to-html.ts' import { adfToLosslessMarkdown } from '../markdown/emit/adf-to-markdown.ts' import { blockNodes } from '../adf/block-nodes.ts' import { htmlToAdf } from '../html/parse/html-to-adf.ts' import { inlineNodes } from '../adf/inline-nodes.ts' -import { isAdfDocument, isRecord } from '../adf/document.ts' +import { isRecord, readAdfDocument } from '../adf/document.ts' import { isJsonValue } from '../json-value.ts' import { losslessMarkdownToAdf } from '../markdown/parse/markdown-to-adf.ts' import { markVocabularies } from '../adf/mark-attributes.ts' @@ -25,6 +26,12 @@ const roundTripRoot = join(corpusRoot, 'round-trip') const roundTripDirectories = ['block-nodes', 'combinations', 'commonmark-subset', 'inline-nodes', 'opaque-carry'] const typesNoFetchedDocumentHolds = ['bodiedSyncBlock', 'decisionItem', 'decisionList', 'extensionFrame', 'fragment', 'multiBodiedExtension', 'syncBlock'] +function documentIn(json: unknown, name: string): AdfDocument { + const read = readAdfDocument(json) + assert.ok(read.ok, `${name}.json is not an ADF document`) + return read.value +} + function directoryNames(root: string): string[] { return readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -84,8 +91,7 @@ for (const directory of roundTripDirectories) { for (const name of names) { test(`${directory}/${name} emits its markdown byte for byte`, () => { - const parsed: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(parsed), `${name}.json is not an ADF document`) + const parsed = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name) const result = adfToLosslessMarkdown(parsed) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) const expected = readFileSync(join(roundTripRoot, directory, `${name}.md`)) @@ -99,8 +105,7 @@ for (const directory of roundTripDirectories) { for (const directory of roundTripDirectories) { for (const name of fixtureNames(directory, '.md')) { test(`${directory}/${name} reads its markdown back to the document beside it`, () => { - const expected: unknown = JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) + const expected = documentIn(JSON.parse(readFileSync(join(roundTripRoot, directory, `${name}.json`), 'utf8')), name) const result = losslessMarkdownToAdf(readFileSync(join(roundTripRoot, directory, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.deepEqual(result.value, expected) @@ -127,8 +132,7 @@ test('no round-trip fixture repeats the document another holds', () => { for (const name of pairedNames(normalizationRoot, '.md', '.json')) { test(`normalization/${name} parses to the document beside it, which emits and reads back to itself`, () => { - const expected: unknown = JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(expected), `${name}.json is not an ADF document`) + const expected = documentIn(JSON.parse(readFileSync(join(normalizationRoot, `${name}.json`), 'utf8')), name) const result = losslessMarkdownToAdf(readFileSync(join(normalizationRoot, `${name}.md`), 'utf8')) assert.ok(result.ok, result.ok ? '' : `${result.error.code}: ${result.error.message}`) assert.deepEqual(result.value, expected) @@ -149,8 +153,7 @@ for (const root of [realPayloadsRoot, realWorldRoot]) { for (const name of names(root, '.json')) { test(`${kind}/${name} emits markdown that reads back to it`, () => { - const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`) + const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name) const emitted = adfToLosslessMarkdown(payload) assert.ok(emitted.ok, emitted.ok ? '' : `${emitted.error.code}: ${emitted.error.message}`) const parsed = losslessMarkdownToAdf(emitted.value) @@ -159,8 +162,7 @@ for (const root of [realPayloadsRoot, realWorldRoot]) { }) test(`${kind}/${name} writes HTML that reads back to it`, () => { - const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`) + const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name) const written = adfToHtml(payload) assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`) assert.deepEqual(htmlToAdf(written.value), { ok: true, value: payload }) @@ -174,8 +176,7 @@ const blockElements: Readonly> = { blockquote: ' { - const payload: unknown = JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')) - assert.ok(isAdfDocument(payload), `${name}.json is not an ADF document`) + const payload = documentIn(JSON.parse(readFileSync(join(root, `${name}.json`), 'utf8')), name) assert.ok(adfToHtml(payload).ok) for (const block of payload.content ?? []) { const opening = Object.hasOwn(blockElements, block.type) ? blockElements[block.type] : undefined diff --git a/src/html/emit/adf-to-html.ts b/src/html/emit/adf-to-html.ts index 28272d1..1424e18 100644 --- a/src/html/emit/adf-to-html.ts +++ b/src/html/emit/adf-to-html.ts @@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu import { firstParagraphForm } from '../first-paragraph.ts' import { imageElement } from '../image-element.ts' import { inlineHtml, writableText } from './inline.ts' -import { isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts' +import { isUnmarkedSpellableText, nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../../adf/document.ts' import { languageClass } from '../language-class.ts' import { largestNesting } from '../../nesting.ts' import { longestOutput, tooLong, withinEngineLimits } from '../../length-bound.ts' @@ -24,7 +24,10 @@ type Row = { depth: number; kind: 'row'; path: ConvertErrorPath; pieces: Pending type Pending = Block | Item | Row | string export function adfToHtml(document: AdfDocument): Result { - return withinEngineLimits(() => writeHtml(document)) + return withinEngineLimits(() => { + const read = readAdfDocument(document) + return read.ok ? writeHtml(read.value) : read + }) } // A stack rather than recursion, since each level would cost several frames: a block returns its pieces in document order, and only this loop pushes them. diff --git a/src/index.ts b/src/index.ts index f465587..a8b2de4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -// Export only the conversions, their types, isAdfDocument and what a guarantee or a persona needs. +// Export only the conversions, their types, readAdfDocument and what a guarantee or a persona needs. export type { AdfAttributes, AdfDocument, AdfMark, AdfNode } from './adf/document.ts' export type { ConvertError, ConvertErrorCode, ConvertErrorPath, ParseError, Result, SourcePosition } from './result.ts' export type { JsonValue } from './json-value.ts' @@ -7,5 +7,5 @@ export { adfToLosslessMarkdown } from './markdown/emit/adf-to-markdown.ts' export { adfToPortableMarkdown } from './markdown/portable/adf-to-portable-markdown.ts' export { commonMarkToAdf, losslessMarkdownToAdf } from './markdown/parse/markdown-to-adf.ts' export { htmlToAdf } from './html/parse/html-to-adf.ts' -export { isAdfDocument } from './adf/document.ts' export { portableMarkdownToAdf } from './markdown/portable/portable-markdown-to-adf.ts' +export { readAdfDocument } from './adf/document.ts' diff --git a/src/json-value.ts b/src/json-value.ts index cf7ef3b..d009787 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -20,7 +20,7 @@ export function isJsonValue(value: unknown): value is JsonValue { } // Any realm's Object.prototype has a null prototype; serializeCanonicalJson writes a Date or Map as {}. -function isPlainObject(value: unknown): value is object { +export function isPlainObject(value: unknown): value is object { if (typeof value !== 'object' || value === null) return false const prototype: unknown = Object.getPrototypeOf(value) return prototype === null || (typeof prototype === 'object' && Object.getPrototypeOf(prototype) === null) diff --git a/src/markdown/emit/adf-to-markdown.ts b/src/markdown/emit/adf-to-markdown.ts index c958ea2..e243bcd 100644 --- a/src/markdown/emit/adf-to-markdown.ts +++ b/src/markdown/emit/adf-to-markdown.ts @@ -10,7 +10,7 @@ import { failure, success, type ConvertErrorPath, type Result } from '../../resu import { fencedCodeBlock } from '../commonmark/backtick-runs.ts' import { flavourClaims } from '../flavour.ts' import { holdsNullCharacter, markerInterruptsParagraph } from '../commonmark/grammar.ts' -import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, writableContent } from '../../adf/document.ts' +import { holdsOnlyAttributes, isUnmarkedSpellableText, nodeAttrs, nodeContent, readAdfDocument, writableContent } from '../../adf/document.ts' import { languageSlot, type LanguageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { withinEngineLimits, withinLongest } from '../../length-bound.ts' @@ -48,7 +48,10 @@ const listItemOpener = spellDirectiveOpener('listItem', undefined, '') const orderedDelimiters: Delimiters = ['.', ')'] export function adfToLosslessMarkdown(document: AdfDocument): Result { - return withinEngineLimits(() => writeMarkdown(document, { flavour: 'lossless' })) + return withinEngineLimits(() => { + const read = readAdfDocument(document) + return read.ok ? writeMarkdown(read.value, { flavour: 'lossless' }) : read + }) } export function writeMarkdown(document: AdfDocument, writtenAs: WrittenAs): Result { diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index de11ad3..08b90a4 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -10,7 +10,7 @@ import { isBlockNodeType, oneLine } from '../../adf/shown-text.ts' import { languageSlot } from '../code-language.ts' import { largestNesting } from '../../nesting.ts' import { linkAttrs } from '../../adf/mark-attributes.ts' -import { nodeAttrs, nodeContent, nodeMarks, writableContent } from '../../adf/document.ts' +import { nodeAttrs, nodeContent, nodeMarks, readAdfDocument, writableContent } from '../../adf/document.ts' import { portableWriting } from './block-spelling.ts' import { splitTaskList, taskListItems } from './task-list.ts' import { taskMarker } from './conventions.ts' @@ -53,7 +53,8 @@ const blockReducers: Readonly> = { export function adfToPortableMarkdown(document: AdfDocument): Result { return withinEngineLimits(() => { - const reduced = reduceToPortable(document) + const read = readAdfDocument(document) + const reduced = read.ok ? reduceToPortable(read.value) : read return reduced.ok ? writeMarkdown(reduced.value, portableWriting) : reduced }) } diff --git a/todo.md b/todo.md index 9ea60a8..e7e7643 100644 --- a/todo.md +++ b/todo.md @@ -13,7 +13,6 @@ | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | |---|---|---|---|---|---|---|---|---|---|---|---| | 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 | -| 45 | 0.4.0 | | defect | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 1 | 3 | 4 | 3 | 1 by 2 | 13.1 | | 127 | 0.4.0 | | principle | **Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text.** | 2 | 2 | 1 | 3 | 3 | code health by 2 | 13.1 | | 159 | 0.4.0 | | | **Delete `taskBlocks`'s branch that writes the task marker as its own paragraph, once a test shows no block task item reaches it.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 10.5 | | 144 | 0.4.0 | | | **Give every container `docker-runner.sh` and `publish.sh` start a memory limit and an equal swap limit, sized from the largest a gate leg measurably needs.** | 1 | 1 | 1 | 1 | 2 | code health by 1 | 8.7 | @@ -35,7 +34,6 @@ | 148 | 0.4.0 | | | **Ship conversion between ADF and Jira wiki markup both ways, under a README goal for wiki markup.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | | 149 | 0.4.0 | | | **Read any HTML into ADF, in a call or option a developer panel picks, under a README goal for foreign HTML.** | 3 | 2 | 2 | 5 | 7 | 3 by 5 | 0.9 | | 172 | 0.4.0 | | principle | **Build one test per reader or case in place of a loop inside a test body, in the 26 test files that loop.** | 3 | 1 | 1 | 3 | 8 | code health by 2 | 0.8 | -| 77 | 0.4.0 | | decision | **Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result.** | 3 | 1 | 3 | 5 | 4 | | 0.0 | | 107 | 0.4.0 | | decision | **Spell the block carry's JSON so its length stays linear in the node's JSON.** | 3 | 1 | 3 | 5 | 5 | | 0.0 | | 170 | 0.4.0 | | owner | **Bundle the packed tarball with webpack, esbuild, Rollup and Bun in the gate, failing on any bundler warning, and run each bundle's round-trip in Node.** | 3 | 1 | 1 | 3 | 5 | | 0.0 | | 42 | 0.5.0 | | defect | **Trim a text leaf's trailing blanks in linear time.** | 1 | 1 | 2 | 2 | 2 | 9 by 3 | 26.2 | @@ -70,13 +68,6 @@ a paragraph opening with a spellable `!adf:link` (§Nothing recurses unbounded). `src/markdown/**` bans only `html/`; banning `emit/`, `parse/` and `portable/` for the root's own files, with its `ci.sh` probe, extends the decision. -### 45. Replace `isAdfDocument` with a reader returning `Result`. - -Lands with item 77: the reader returns item 77's copy. Goal 1 has every call return a result; the -boolean guard is the one export that does not, and it cannot say which branch refused, where -`not-an-adf-document`'s message already does. Breaking: `MIGRATION.md` shows the guard's -replacement. - ### 127. Hold what `entity-references.test.ts`, `json-text.test.ts` and `result.test.ts` check by asserting outcomes, so none reads source text. Technical principle "Assert the outcome", which bans scanning the source with `ast` or a regex. @@ -152,7 +143,7 @@ the short row and the line holding no `|` for `losslessMarkdownToAdf` alone. The ### 80. Name the node's path and the attribute key when `not-an-adf-document` refuses a node inside the document. -Lands with items 45 and 77, whose reader walks every node. Today every refused node, such as one +Rides on `readAdfDocument`, whose copy walks every node. Today every refused node, such as one with a `Date` in an attribute, reads `an ADF document's content holds ADF nodes: one of them is not` with an empty `path`. An export tool told this about one document in thousands then searches that document's whole tree. Give the node's path, and name its type and the attribute key as @@ -287,24 +278,6 @@ first failing reader stops the test and the failure never names the reader. Writ "No loops or branches in a test body; parametrize the cases". Found by the principles audit of the release review of 0.3.0, 2026-11-08. -### 77. Read the input once into a plain copy, so an accessor property or a throwing Proxy yields `not-an-adf-document` and every call returns a result. - -Lands with item 45, in one chunk: item 45's reader returns the copy. The maintainer's call, -2026-10-04: Goal 1 promises a result for any value, getters and Proxies included. Today the guards -and the conversions read the caller's object many times, so a throwing getter, a revoked Proxy or a -getter minting a fresh child per access throws or walks forever. Item 45's reader copies the -document once inside one tight `try/catch`, reading property descriptors so no getter runs: an -accessor property, or a Proxy trap that throws, is `not-an-adf-document`. The copy keeps own -enumerable keys, as `JSON.stringify` does, so an inherited or non-enumerable key drops. The reader -reads arrays by index, so no custom iterator runs. Every later step works on the copy. A Proxy can -still claim unbounded width, which the 500-level limit does not cover, so the copy reads at most 10 -million JSON values, each object, array, string, number, boolean and null counting one (the -maintainer, 2026-10-05): roughly 50 to 100 MB of JSON, far past any real page, where 10 000 would -refuse a 500-row table. Before reading an array, the reader checks its `length` against the values -left, so a Proxy claiming 2³² elements is refused at once. The chunk asks whether that refusal -shares the 500-level guard's `unsupported-nesting-depth` or takes another code. Found by the -technical-principles audit, 2026-10-04. - ### 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 about 1 MB of deeply nested arrays in a carried