Refuse a cyclic input as not-an-adf-document #159

Merged
lilleman merged 18 commits from cyclic-input into main 2026-10-04 19:36:20 +02:00
7 changed files with 67 additions and 17 deletions
+1
View File
@@ -54,6 +54,7 @@ In `docs/decisions.md`:
- The flavour spec is read as a source - The flavour spec is read as a source
- The node tables answer to Atlassian's schema - The node tables answer to Atlassian's schema
- Nothing recurses unbounded - Nothing recurses unbounded
- No value `JSON.parse` or `structuredClone` builds loops a call
- Nothing spreads an unbounded array - Nothing spreads an unbounded array
- A retry loop checks its own termination - A retry loop checks its own termination
- Readers scan by index - Readers scan by index
+2
View File
@@ -31,6 +31,8 @@
`collection`, `id` and `occurrenceKey` directly where they rode the opaque carry. `collection`, `id` and `occurrenceKey` directly where they rode the opaque carry.
- Fix an image inside another image's description: it flattens into the alt text, where it was - Fix an image inside another image's description: it flattens into the alt text, where it was
refused. refused.
- Fix a cyclic document, in which an object holds itself: `adfToMarkdown` and `adfToPlainMarkdown`
refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung.
## 0.1.0 ## 0.1.0
+4 -4
View File
@@ -33,7 +33,7 @@ The most useful ADF conversion library available, judged by these goals, in prio
5. **No surprises: output reads and edits the way its audience expects.** 5. **No surprises: output reads and edits the way its audience expects.**
6. **Lossy conversion drops form, never content.** 6. **Lossy conversion drops form, never content.**
7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.** 7. **Runs in any JavaScript engine, with no runtime dependencies and nothing to configure or connect.**
8. **Fast, and linear in the document's size.** 8. **Fast, and linear in the document's size as JSON.**
9. **Easy to find, and clear at a glance what it does.** 9. **Easy to find, and clear at a glance what it does.**
## Audience ## Audience
@@ -185,7 +185,7 @@ Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`:
| Code | Fires when | What you can do | | 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 | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused | | `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 | guard the boundary you receive JSON at with `isAdfDocument`; the message names the branch that refused |
| `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 | | `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. The emitter's own refusals are in this group — a parse reaches them by asking it Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it
@@ -238,8 +238,8 @@ Serves Goals 1, 3 and 4.
input. input.
- Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist - Past that and `~~`, no GFM: an autolink literal and a `- [ ]` marker stay text, and a checklist
is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`. is the `taskList` directive — `plainMarkdownToAdf` turns the marker into a `taskList`.
- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input - A document nested deeper than 500 levels is an error result, not a stack overflow, and no call
makes a call loop forever. loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds.
- The emitted formats are semver surface - The emitted formats are semver surface
([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)). ([`docs/decisions.md`](https://gitea.larvit.se/larvit/adf-codec/src/branch/main/docs/decisions.md#the-formats-are-api)).
- **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides
+13 -4
View File
@@ -384,10 +384,10 @@ input reads `message`.
must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose must invert before it reads as a failure — and where the flavour's claim refuses ordinary prose
it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and
inline alike, `\|` for every pipe row. inline alike, `\|` for every pipe row.
- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - `not-an-adf-document` carries the document's own path throughout: seven of the guard's nine
branches read the document's own shape, and threading a path to the eighth — a malformed node branches read the document's own shape, and threading a path to the other two, which look anywhere
anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing
names the violation instead. recurses unbounded forces. The message names the violation instead.
## Publish on a version bump ## Publish on a version bump
@@ -563,6 +563,15 @@ format, as its parser does. A list giving way to the directive form refuses at z
than walking again; counting every list twice halved the list limit, counting the directive form than walking again; counting every list twice halved the list limit, counting the directive form
once doubled the parser's frames per level. once doubled the parser's frames per level.
## No value `JSON.parse` or `structuredClone` builds loops a call
2026-10-04, the maintainer. Goals 1 and 8. Valid while every persona hands in plain data, as
`JSON.parse` or `structuredClone` builds it.
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 8's size counts ADF as JSON, so a shared object costs once per place it is held.
## Nothing spreads an unbounded array ## Nothing spreads an unbounded array
2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments. 2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments.
+20
View File
@@ -82,3 +82,23 @@ test("reads a node's siblings as a walk rather than as one call's arguments", ()
assert.equal(isAdfDocument(wide), true) assert.equal(isAdfDocument(wide), true)
assert.equal(fault(wide), 'accepted') 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 paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' }
paragraph.content.push(paragraph)
assert.equal(fault({ 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)
const attrs: Record<string, unknown> = {}
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)
const bold = { type: 'strong' }
const text = { marks: [bold], text: 'x', type: 'text' }
const shared = { content: [text, text], type: 'paragraph' }
const values = { b: 1 }
assert.equal(fault({ content: [shared, shared, { attrs: { a: [values, values] }, type: 'paragraph' }], type: 'doc', version: 1 }), 'accepted')
})
+18
View File
@@ -43,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | undefined {
const held: unknown = value['content'] const held: unknown = value['content']
if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`) if (!Array.isArray(held)) return notADocument(`an ADF document's content is an array: found ${describe(held)}`)
const content: readonly unknown[] = 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") if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not")
return nestingFault(content) return nestingFault(content)
} }
@@ -122,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] {
return node.marks ?? [] return node.marks ?? []
} }
// 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<unknown>()
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 readonly AdfNode[] {
const pending: unknown[] = [...value] const pending: unknown[] = [...value]
while (pending.length > 0) { while (pending.length > 0) {
+9 -9
View File
@@ -6,7 +6,7 @@
`Bar = 9` `Bar = 9`
`Next ID = 66` `Next ID = 67`
| Goal | W | | Goal | W |
|---|---| |---|---|
@@ -24,12 +24,12 @@
| ID | Release | Exempt | Item | R | S | A | G | Goals | Score | | ID | Release | Exempt | Item | R | S | A | G | Goals | Score |
|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|
| 63 | 0.2.0 | defect | **Refuse a cyclic input as `not-an-adf-document`.** | 2 | 2 | 6 | 9 | 1 | 27.5 |
| 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 | | 7 | 0.2.0 | | **Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.** | 6 | 9 | 9 | 9 | 2, 3 | 25.8 |
| 55 | 0.2.0 | defect | **Spell every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.** | 5 | 5 | 7 | 9 | 1 | 25.8 | | 55 | 0.2.0 | defect | **Spell every document the emitter refuses today for a carriage return, a NUL, a code-span line start, a newline inside an emoji, mention or status, or a text node whose text is empty or missing, or which holds content.** | 5 | 5 | 7 | 9 | 1 | 25.8 |
| 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result<AdfDocument>`.** | 2 | 3 | 6 | 8 | 1 | 25.2 |
| 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 | | 6 | 0.2.0 | decision | **Specify the HTML dialect.** | 2 | 6 | 7 | 8 | 2, 3 | 24.7 |
| 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 7 | 23.0 | | 64 | 0.2.0 | defect | **Read back unchanged on V8 every JSON key the emitter writes, with a fixture whose key holds `\`, `"` or a control character.** | 4 | 4 | 6 | 8 | 1, 7 | 23.0 |
| 66 | 0.2.0 | defect | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 |
| 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 | | 43 | 0.2.0 | decision | **Give each markdown input its own reader, strict to its own standard.** | 6 | 7 | 8 | 9 | 3, 4 | 22.3 |
| 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 3, 4, 6 | 18.7 | | 65 | 0.2.0 | defect | **Read an image not alone in its paragraph as its alt text linked to its URL, and a titled image alone in its paragraph as the image captioned with its title.** | 5 | 5 | 7 | 7 | 3, 4, 6 | 18.7 |
| 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 | | 49 | 0.2.0 | | **Read a list whose bullet or ordered delimiter changes as two lists, in both readers, and retire `!adf:listBreak`.** | 4 | 5 | 5 | 8 | 3, 4 | 17.2 |
@@ -57,13 +57,6 @@
## Details ## Details
### 63. Refuse a cyclic input as `not-an-adf-document`.
`adfDocumentFault` walks with `isNodeArray` (`adf/document.ts`) and `isJsonValue` (`json-value.ts`),
worklists that record no visited object, so a node whose `content` holds itself hangs
`adfToMarkdown`, `adfToPlainMarkdown` and `isAdfDocument`; README §The guarantees promises no input
loops forever.
### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF. ### 7. Ship HTML: `adfToHtml`, `htmlToAdf`, and `markdownToHtml` / `htmlToMarkdown` composed through ADF.
Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against
@@ -109,6 +102,13 @@ Deno and Chrome the round-trip would refuse its own output or read back a differ
tells which. Confirm with a fixture first; then read JSON with our own parser or record the gap with tells which. Confirm with a fixture first; then read JSON with our own parser or record the gap with
an ending item. Found by the README-goals audit, 2026-10-03. an ending item. Found by the README-goals audit, 2026-10-03.
### 66. Refuse an attribute value that is not a plain object, array or JSON primitive.
`isJsonValue` (`json-value.ts`) walks any object by `Object.values`, so a `Date`, `Map`, `Set` or
typed array in `attrs`, as `structuredClone` keeps it, passes the guard and is written as `{}` or a
key per element: `ok: true` with content lost. Accept an object whose prototype is
`Object.prototype` or `null`, and an array. Found by the stability review of item 63, 2026-10-04.
### 43. Give each markdown input its own reader, strict to its own standard. ### 43. Give each markdown input its own reader, strict to its own standard.
Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a