From f9f863fb28c29fdd60ce17f6d9dd034111e0945e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 16:57:51 +0200 Subject: [PATCH 01/18] Refuse a cyclic input as not-an-adf-document --- CHANGELOG.md | 2 ++ src/adf/document.test.ts | 20 ++++++++++++++++++++ src/adf/document.ts | 27 +++++++++++---------------- src/json-value.ts | 37 +++++++++++++++++++++++++------------ todo.md | 8 -------- 5 files changed, 58 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3123e48..70581f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ `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 refused. +- Fix a document holding itself, through a node's `content` or an attribute: `adfToMarkdown`, + `adfToPlainMarkdown` and `isAdfDocument` refuse it as `not-an-adf-document`, where they hung. ## 0.1.0 diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 20e3b37..5a7d9b1 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -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(fault(wide), 'accepted') }) + +test('refuses a document that holds itself, and accepts one sharing a node or a mark', () => { + const notANode = "an ADF document's content holds ADF nodes: one of them is not" + const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' } + paragraph.content.push(paragraph) + assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), notANode) + const document: { content: unknown[]; type: string; version: number } = { content: [], type: 'doc', version: 1 } + document.content.push(document) + assert.equal(fault(document), notANode) + const attrs: Record = {} + attrs['a'] = [attrs] + assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), notANode) + assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), notANode) + 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') +}) diff --git a/src/adf/document.ts b/src/adf/document.ts index 772dd34..aa67faa 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,5 +1,5 @@ import type { ConvertFault } from '../result.ts' -import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' +import { everyInTree, isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' import { serializeCanonicalJson } from '../canonical-json.ts' @@ -123,21 +123,16 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] { } function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { - const pending: unknown[] = [...value] - while (pending.length > 0) { - const node = pending.pop() - if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false - if (typeof node['type'] !== 'string') return false - if ('attrs' in node && !isAttributes(node['attrs'])) return false - if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false - if ('text' in node && typeof node['text'] !== 'string') return false - if ('content' in node) { - const content = node['content'] - if (!Array.isArray(content)) return false - for (const child of content) pending.push(child) - } - } - return true + return everyInTree(value, (node) => { + if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return undefined + if (typeof node['type'] !== 'string') return undefined + if ('attrs' in node && !isAttributes(node['attrs'])) return undefined + if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return undefined + if ('text' in node && typeof node['text'] !== 'string') return undefined + if (!('content' in node)) return [] + const content = node['content'] + return Array.isArray(content) ? [...content] : undefined + }) } function marksKey(marks: readonly AdfMark[]): string { diff --git a/src/json-value.ts b/src/json-value.ts index 6448b4d..f444ee1 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -2,23 +2,36 @@ import { largestNesting } from './nesting.ts' export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } -export function isJsonValue(value: unknown): value is JsonValue { - const pending: unknown[] = [value] +// Tracks ancestors alone: a tree may share an object, but one holding itself would walk forever. +export function everyInTree(roots: readonly unknown[], children: (item: unknown) => readonly unknown[] | undefined): boolean { + const pending = Array.from(roots, (item) => ({ depth: 0, item })) + const ancestors: unknown[] = [] + const onPath = new Set() while (pending.length > 0) { - const item = pending.pop() - if (item === null || typeof item === 'boolean' || typeof item === 'string') continue - if (typeof item === 'number') { - if (!Number.isFinite(item)) return false - continue - } - // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. - if (Array.isArray(item)) for (const child of [...item]) pending.push(child) - else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child) - else return false + const entry = pending.pop() + if (entry === undefined) continue + while (ancestors.length > entry.depth) onPath.delete(ancestors.pop()) + if (onPath.has(entry.item)) return false + const held = children(entry.item) + if (held === undefined) return false + if (held.length === 0) continue + ancestors.push(entry.item) + onPath.add(entry.item) + for (const child of held) pending.push({ depth: entry.depth + 1, item: child }) } return true } +export function isJsonValue(value: unknown): value is JsonValue { + return everyInTree([value], (item) => { + if (item === null || typeof item === 'boolean' || typeof item === 'string') return [] + if (typeof item === 'number') return Number.isFinite(item) ? [] : undefined + // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. + if (Array.isArray(item)) return [...item] + return typeof item === 'object' ? Object.values(item) : undefined + }) +} + export function nestingDepth(value: unknown): number { const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] let deepest = 0 diff --git a/todo.md b/todo.md index 54b9978..a367e4f 100644 --- a/todo.md +++ b/todo.md @@ -24,7 +24,6 @@ | 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 | | 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`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | @@ -57,13 +56,6 @@ ## 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. Lands after items 6, 59, 60, 61 and 62. The CommonMark spec suite also runs against -- 2.52.0 From bf610fc43854cc42cbfe9a4a5d3dda544a5e5d15 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:03:21 +0200 Subject: [PATCH 02/18] Answer security round 1: clean --- src/adf/document.ts | 27 ++++++++++++++++----------- src/json-value.ts | 37 ++++++++++++------------------------- 2 files changed, 28 insertions(+), 36 deletions(-) diff --git a/src/adf/document.ts b/src/adf/document.ts index aa67faa..772dd34 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -1,5 +1,5 @@ import type { ConvertFault } from '../result.ts' -import { everyInTree, isJsonValue, overNested, type JsonValue } from '../json-value.ts' +import { isJsonValue, overNested, type JsonValue } from '../json-value.ts' import { largestNesting } from '../nesting.ts' import { serializeCanonicalJson } from '../canonical-json.ts' @@ -123,16 +123,21 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] { } function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { - return everyInTree(value, (node) => { - if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return undefined - if (typeof node['type'] !== 'string') return undefined - if ('attrs' in node && !isAttributes(node['attrs'])) return undefined - if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return undefined - if ('text' in node && typeof node['text'] !== 'string') return undefined - if (!('content' in node)) return [] - const content = node['content'] - return Array.isArray(content) ? [...content] : undefined - }) + const pending: unknown[] = [...value] + while (pending.length > 0) { + const node = pending.pop() + if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false + if (typeof node['type'] !== 'string') return false + if ('attrs' in node && !isAttributes(node['attrs'])) return false + if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false + if ('text' in node && typeof node['text'] !== 'string') return false + if ('content' in node) { + const content = node['content'] + if (!Array.isArray(content)) return false + for (const child of content) pending.push(child) + } + } + return true } function marksKey(marks: readonly AdfMark[]): string { diff --git a/src/json-value.ts b/src/json-value.ts index f444ee1..6448b4d 100644 --- a/src/json-value.ts +++ b/src/json-value.ts @@ -2,36 +2,23 @@ import { largestNesting } from './nesting.ts' export type JsonValue = JsonValue[] | boolean | null | number | string | { [key: string]: JsonValue } -// Tracks ancestors alone: a tree may share an object, but one holding itself would walk forever. -export function everyInTree(roots: readonly unknown[], children: (item: unknown) => readonly unknown[] | undefined): boolean { - const pending = Array.from(roots, (item) => ({ depth: 0, item })) - const ancestors: unknown[] = [] - const onPath = new Set() +export function isJsonValue(value: unknown): value is JsonValue { + const pending: unknown[] = [value] 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 false - const held = children(entry.item) - if (held === undefined) return false - if (held.length === 0) continue - ancestors.push(entry.item) - onPath.add(entry.item) - for (const child of held) pending.push({ depth: entry.depth + 1, item: child }) + const item = pending.pop() + if (item === null || typeof item === 'boolean' || typeof item === 'string') continue + if (typeof item === 'number') { + if (!Number.isFinite(item)) return false + continue + } + // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. + if (Array.isArray(item)) for (const child of [...item]) pending.push(child) + else if (typeof item === 'object') for (const child of Object.values(item)) pending.push(child) + else return false } return true } -export function isJsonValue(value: unknown): value is JsonValue { - return everyInTree([value], (item) => { - if (item === null || typeof item === 'boolean' || typeof item === 'string') return [] - if (typeof item === 'number') return Number.isFinite(item) ? [] : undefined - // A hole is not a JSON value, and Array.prototype methods skip holes — spreading materialises them. - if (Array.isArray(item)) return [...item] - return typeof item === 'object' ? Object.values(item) : undefined - }) -} - export function nestingDepth(value: unknown): number { const pending: { depth: number; item: unknown }[] = [{ depth: 0, item: value }] let deepest = 0 -- 2.52.0 From 438bf8876b2a7fffe3eb0e040ac1c19324d778d1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:03:21 +0200 Subject: [PATCH 03/18] Answer stability round 1: nits moot once the walks revert -- 2.52.0 From addef08fa40c4ce9d915d671bed50637bd8af4f7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:03:21 +0200 Subject: [PATCH 04/18] Answer product-owner round 1: name the cycle in its own message and in the error table --- CHANGELOG.md | 5 +++-- README.md | 2 +- docs/decisions.md | 6 +++--- src/adf/document.test.ts | 12 ++++++------ src/adf/document.ts | 18 ++++++++++++++++++ 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70581f1..dcd02fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,8 +31,9 @@ `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 refused. -- Fix a document holding itself, through a node's `content` or an attribute: `adfToMarkdown`, - `adfToPlainMarkdown` and `isAdfDocument` refuse it as `not-an-adf-document`, where they hung. +- Fix a cyclic document, in which a node or an attribute value holds itself: `adfToMarkdown` and + `adfToPlainMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, + where all three hung. ## 0.1.0 diff --git a/README.md b/README.md index e0e48f1..40826c5 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: | 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, a node or attribute value 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 | Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it diff --git a/docs/decisions.md b/docs/decisions.md index 1f0c535..789633c 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -384,9 +384,9 @@ input reads `message`. 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 inline alike, `\|` for every pipe row. -- `not-an-adf-document` carries the document's own path throughout: seven of the guard's eight - branches read the document's own shape, and threading a path to the eighth — a malformed node - anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message +- `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 other two — a malformed node, + or an object holding itself, anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message names the violation instead. ## Publish on a version bump diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 5a7d9b1..bb9ccab 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -83,18 +83,18 @@ test("reads a node's siblings as a walk rather than as one call's arguments", () assert.equal(fault(wide), 'accepted') }) -test('refuses a document that holds itself, and accepts one sharing a node or a mark', () => { - const notANode = "an ADF document's content holds ADF nodes: one of them is not" +test('refuses a node or an attribute value holding itself, and accepts one shared', () => { + const holdsItself = 'an ADF document is a tree: a node or an attribute value holds itself' const paragraph: { content: unknown[]; type: string } = { content: [], type: 'paragraph' } paragraph.content.push(paragraph) - assert.equal(fault({ content: [paragraph], type: 'doc', version: 1 }), notANode) + 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), notANode) + assert.equal(fault(document), holdsItself) const attrs: Record = {} attrs['a'] = [attrs] - assert.equal(fault({ content: [{ attrs, type: 'paragraph' }], type: 'doc', version: 1 }), notANode) - assert.equal(fault({ content: [{ marks: [{ attrs, type: 'link' }], text: 'x', type: 'text' }], type: 'doc', version: 1 }), notANode) + 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' } diff --git a/src/adf/document.ts b/src/adf/document.ts index 772dd34..3e138eb 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -43,6 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | 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: a node or an attribute value holds itself') if (!isNodeArray(content)) return notADocument("an ADF document's content holds ADF nodes: one of them is not") return nestingFault(content) } @@ -122,6 +123,23 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] { return node.marks ?? [] } +// 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 + for (const left of ancestors.splice(entry.depth)) onPath.delete(left) + 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[] { const pending: unknown[] = [...value] while (pending.length > 0) { -- 2.52.0 From 261dc3a5cf0620a96c5c0151be384af474ee096d Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:03:21 +0200 Subject: [PATCH 05/18] Answer prose round 1: CHANGELOG states what isAdfDocument returns -- 2.52.0 From 9778844c11dd63c3ae185333cd8f031a62b797aa Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:12:46 +0200 Subject: [PATCH 06/18] Answer stability round 2: refuse a node or mark holding a key it does not own --- CHANGELOG.md | 5 ++--- README.md | 2 +- docs/decisions.md | 6 +++--- src/adf/document.test.ts | 14 +++++++++++++- src/adf/document.ts | 17 +++++++++++------ 5 files changed, 30 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dcd02fe..5e1c5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,8 @@ `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 refused. -- Fix a cyclic document, in which a node or an attribute value holds itself: `adfToMarkdown` and - `adfToPlainMarkdown` refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, - where all three hung. +- 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 diff --git a/README.md b/README.md index 40826c5..71cde23 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ Emitting — `adfToMarkdown`, and `adfToHtml` at `0.2.0`: | 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, a node or attribute value holding itself | 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 | Either direction. The emitter's own refusals are in this group — a parse reaches them by asking it diff --git a/docs/decisions.md b/docs/decisions.md index 789633c..415f3a2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -385,9 +385,9 @@ input reads `message`. it names the escape that unclaims the form claimed: `\!adf:` for a directive, block line and inline alike, `\|` for every pipe row. - `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 other two — a malformed node, - or an object holding itself, anywhere in the tree — wants the manual stack §Nothing recurses unbounded forces. The message - names the violation instead. + branches read the document's own shape, and threading a path to the other two, which look anywhere + in the tree — a malformed node, or an object holding itself — wants the manual stack §Nothing + recurses unbounded forces. The message names the violation instead. ## Publish on a version bump diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index bb9ccab..af13f46 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -84,7 +84,7 @@ test("reads a node's siblings as a walk rather than as one call's arguments", () }) test('refuses a node or an attribute value holding itself, and accepts one shared', () => { - const holdsItself = 'an ADF document is a tree: a node or an attribute value holds itself' + 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) @@ -96,6 +96,18 @@ test('refuses a node or an attribute value holding itself, and accepts one share 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 notANode = "an ADF document's content holds ADF nodes: one of them is not" + const inherits: { content?: unknown[] } = {} + const heir: Record = Object.create(inherits) + heir['type'] = 'paragraph' + inherits.content = [heir] + assert.equal(fault({ content: [heir], type: 'doc', version: 1 }), notANode) + const hidden: Record = { type: 'paragraph' } + Object.defineProperty(hidden, 'content', { value: [hidden] }) + assert.equal(fault({ content: [hidden], type: 'doc', version: 1 }), notANode) + const link: Record = Object.create({ attrs }) + link['type'] = 'link' + assert.equal(fault({ content: [{ marks: [link], text: 'x', type: 'text' }], type: 'doc', version: 1 }), notANode) const bold = { type: 'strong' } const text = { marks: [bold], text: 'x', type: 'text' } const shared = { content: [text, text], type: 'paragraph' } diff --git a/src/adf/document.ts b/src/adf/document.ts index 3e138eb..7c4060b 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -43,7 +43,7 @@ export function adfDocumentFault(value: unknown): ConvertFault | 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: a node or an attribute value holds itself') + 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) } @@ -106,7 +106,7 @@ export function isAdfNode(value: unknown): value is AdfNode { } export function isAdfMark(value: unknown): value is AdfMark { - if (!isRecord(value) || !holdsOnly(value, markKeys)) return false + if (!isRecord(value) || !holdsOnly(value, markKeys) || !ownsWhatItHolds(value, markKeys)) return false if (typeof value['type'] !== 'string') return false return !('attrs' in value) || isAttributes(value['attrs']) } @@ -123,15 +123,15 @@ export function nodeMarks(node: { marks?: AdfMark[] }): readonly AdfMark[] { return node.marks ?? [] } -// Ancestors alone: a tree may share an object, but an object holding itself walks the guards forever. +// 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() + const onPath = new Set() while (pending.length > 0) { const entry = pending.pop() if (entry === undefined) continue - for (const left of ancestors.splice(entry.depth)) onPath.delete(left) + while (ancestors.length > entry.depth) onPath.delete(ancestors.pop()) if (onPath.has(entry.item)) return true ancestors.push(entry.item) onPath.add(entry.item) @@ -144,7 +144,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { const pending: unknown[] = [...value] while (pending.length > 0) { const node = pending.pop() - if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false + if (!isRecord(node) || !holdsOnly(node, nodeKeys) || !ownsWhatItHolds(node, nodeKeys)) return false if (typeof node['type'] !== 'string') return false if ('attrs' in node && !isAttributes(node['attrs'])) return false if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false @@ -219,6 +219,11 @@ function extraKey(value: Record, keys: readonly string[]): stri return Object.keys(value).find((key) => !keys.includes(key)) } +// holdsItself walks own enumerable keys alone, so a guard reading an inherited one could walk a cycle forever. +function ownsWhatItHolds(value: Record, keys: readonly string[]): boolean { + return keys.every((key) => !(key in value) || Object.prototype.propertyIsEnumerable.call(value, key)) +} + function holdsOnly(value: Record, keys: readonly string[]): boolean { return extraKey(value, keys) === undefined } -- 2.52.0 From b9fa9bff8577a0d6977fcec053491c0fa6ad8d5f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:12:46 +0200 Subject: [PATCH 07/18] Answer product-owner round 2: the cycle message names any object -- 2.52.0 From e501b95cb38b129f603f2b08d69fdf4c6c50f979 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:12:46 +0200 Subject: [PATCH 08/18] Answer prose round 2: the cycle message, README row and CHANGELOG say an object -- 2.52.0 From d26e4e815260ada55f9fd2c96c09467a2e4bd6f7 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:18:03 +0200 Subject: [PATCH 09/18] Answer stability round 3: walk an array's elements as the guards do --- CHANGELOG.md | 4 +++- src/adf/document.test.ts | 10 +++++++++- src/adf/document.ts | 4 ++-- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1c5cc..791878c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,9 @@ - Fix an image inside another image's description: it flattens into the alt text, where it was 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. + refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. A + node or mark whose `attrs`, `content`, `marks`, `text` or `type` is not its own enumerable key is + refused alike, where it was read. ## 0.1.0 diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index af13f46..1a876fb 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -83,7 +83,7 @@ test("reads a node's siblings as a walk rather than as one call's arguments", () assert.equal(fault(wide), 'accepted') }) -test('refuses a node or an attribute value holding itself, and accepts one shared', () => { +test('refuses an object holding itself or a key it does not own, 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) @@ -108,6 +108,14 @@ test('refuses a node or an attribute value holding itself, and accepts one share const link: Record = Object.create({ attrs }) link['type'] = 'link' assert.equal(fault({ content: [{ marks: [link], text: 'x', type: 'text' }], type: 'doc', version: 1 }), notANode) + const indexed: unknown[] = [] + Object.defineProperty(indexed, 0, { value: indexed }) + assert.equal(fault({ content: [{ attrs: { a: indexed }, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself) + const inheritedIndex: { content?: unknown[]; type: string } = { type: 'paragraph' } + const heirs: unknown[] = Object.setPrototypeOf([], [inheritedIndex]) + heirs.length = 1 + inheritedIndex.content = heirs + assert.equal(fault({ content: [inheritedIndex], type: 'doc', version: 1 }), holdsItself) const bold = { type: 'strong' } const text = { marks: [bold], text: 'x', type: 'text' } const shared = { content: [text, text], type: 'paragraph' } diff --git a/src/adf/document.ts b/src/adf/document.ts index 7c4060b..290258b 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -135,7 +135,7 @@ function holdsItself(root: object): boolean { 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 }) + for (const child of Array.isArray(entry.item) ? [...entry.item] : Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child }) } return false } @@ -219,7 +219,7 @@ function extraKey(value: Record, keys: readonly string[]): stri return Object.keys(value).find((key) => !keys.includes(key)) } -// holdsItself walks own enumerable keys alone, so a guard reading an inherited one could walk a cycle forever. +// holdsItself walks own enumerable keys alone, so a guard reading any other key could walk a cycle forever. function ownsWhatItHolds(value: Record, keys: readonly string[]): boolean { return keys.every((key) => !(key in value) || Object.prototype.propertyIsEnumerable.call(value, key)) } -- 2.52.0 From 638881b5555ae5d11ec2a60bfa24bee1d0010c0f Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:18:03 +0200 Subject: [PATCH 10/18] Answer product-owner round 3: CHANGELOG names the own-key refusal -- 2.52.0 From fb0d2b4eac70a60b7971b58b058fe500d83e56ed Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 17:18:03 +0200 Subject: [PATCH 11/18] Answer prose round 3: the ownsWhatItHolds comment names any other key -- 2.52.0 From 58a9c89dfc6fbe153571d465bc10d25a8e780c60 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:18:55 +0200 Subject: [PATCH 12/18] Record the guards' input boundary and Goal 8's size as JSON, and drop the hardening past it --- AGENTS.md | 1 + CHANGELOG.md | 4 +--- README.md | 6 +++--- docs/decisions.md | 10 ++++++++++ src/adf/document.test.ts | 22 +--------------------- src/adf/document.ts | 11 +++-------- 6 files changed, 19 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 614fcc0..504edfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ In `docs/decisions.md`: - The flavour spec is read as a source - The node tables answer to Atlassian's schema - Nothing recurses unbounded +- The guards take what `JSON.parse` or `structuredClone` builds - Nothing spreads an unbounded array - A retry loop checks its own termination - Readers scan by index diff --git a/CHANGELOG.md b/CHANGELOG.md index 791878c..5e1c5cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,7 @@ - Fix an image inside another image's description: it flattens into the alt text, where it was 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. A - node or mark whose `attrs`, `content`, `marks`, `text` or `type` is not its own enumerable key is - refused alike, where it was read. + refuse it as `not-an-adf-document` and `isAdfDocument` returns `false`, where all three hung. ## 0.1.0 diff --git a/README.md b/README.md index 71cde23..3137576 100644 --- a/README.md +++ b/README.md @@ -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.** 6. **Lossy conversion drops form, never content.** 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.** ## Audience @@ -238,8 +238,8 @@ Serves Goals 1, 3 and 4. input. - 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`. -- A document nested deeper than 500 levels is an error result, not a stack overflow, and no input - makes a call loop forever. +- A document nested deeper than 500 levels is an error result, not a stack overflow, and no value + `JSON.parse` or `structuredClone` builds makes a call loop forever. - 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)). - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides diff --git a/docs/decisions.md b/docs/decisions.md index 415f3a2..5ca97cd 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -563,6 +563,16 @@ 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 once doubled the parser's frames per level. +## The guards take what `JSON.parse` or `structuredClone` builds + +2026-10-04, the maintainer. Goal 1. Valid while every persona hands in a value `JSON.parse`, +`structuredClone` or an object literal builds. + +The guarantees speak of plain data: own enumerable keys and plain arrays, cycles and shared objects +included. The guards walk it by `Object.values` and refuse a cycle. 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, and +each guarded shape asked for another. + ## Nothing spreads an unbounded array 2026-09-18, the maintainer. Goal 1. Valid while engines cap a call's arguments. diff --git a/src/adf/document.test.ts b/src/adf/document.test.ts index 1a876fb..5b66c8f 100644 --- a/src/adf/document.test.ts +++ b/src/adf/document.test.ts @@ -83,7 +83,7 @@ test("reads a node's siblings as a walk rather than as one call's arguments", () assert.equal(fault(wide), 'accepted') }) -test('refuses an object holding itself or a key it does not own, and accepts one shared', () => { +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) @@ -96,26 +96,6 @@ test('refuses an object holding itself or a key it does not own, and accepts one 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 notANode = "an ADF document's content holds ADF nodes: one of them is not" - const inherits: { content?: unknown[] } = {} - const heir: Record = Object.create(inherits) - heir['type'] = 'paragraph' - inherits.content = [heir] - assert.equal(fault({ content: [heir], type: 'doc', version: 1 }), notANode) - const hidden: Record = { type: 'paragraph' } - Object.defineProperty(hidden, 'content', { value: [hidden] }) - assert.equal(fault({ content: [hidden], type: 'doc', version: 1 }), notANode) - const link: Record = Object.create({ attrs }) - link['type'] = 'link' - assert.equal(fault({ content: [{ marks: [link], text: 'x', type: 'text' }], type: 'doc', version: 1 }), notANode) - const indexed: unknown[] = [] - Object.defineProperty(indexed, 0, { value: indexed }) - assert.equal(fault({ content: [{ attrs: { a: indexed }, type: 'paragraph' }], type: 'doc', version: 1 }), holdsItself) - const inheritedIndex: { content?: unknown[]; type: string } = { type: 'paragraph' } - const heirs: unknown[] = Object.setPrototypeOf([], [inheritedIndex]) - heirs.length = 1 - inheritedIndex.content = heirs - assert.equal(fault({ content: [inheritedIndex], type: 'doc', version: 1 }), holdsItself) const bold = { type: 'strong' } const text = { marks: [bold], text: 'x', type: 'text' } const shared = { content: [text, text], type: 'paragraph' } diff --git a/src/adf/document.ts b/src/adf/document.ts index 290258b..6f45a23 100644 --- a/src/adf/document.ts +++ b/src/adf/document.ts @@ -106,7 +106,7 @@ export function isAdfNode(value: unknown): value is AdfNode { } export function isAdfMark(value: unknown): value is AdfMark { - if (!isRecord(value) || !holdsOnly(value, markKeys) || !ownsWhatItHolds(value, markKeys)) return false + if (!isRecord(value) || !holdsOnly(value, markKeys)) return false if (typeof value['type'] !== 'string') return false return !('attrs' in value) || isAttributes(value['attrs']) } @@ -135,7 +135,7 @@ function holdsItself(root: object): boolean { if (onPath.has(entry.item)) return true ancestors.push(entry.item) onPath.add(entry.item) - for (const child of Array.isArray(entry.item) ? [...entry.item] : Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child }) + for (const child of Object.values(entry.item)) if (typeof child === 'object' && child !== null) pending.push({ depth: entry.depth + 1, item: child }) } return false } @@ -144,7 +144,7 @@ function isNodeArray(value: readonly unknown[]): value is readonly AdfNode[] { const pending: unknown[] = [...value] while (pending.length > 0) { const node = pending.pop() - if (!isRecord(node) || !holdsOnly(node, nodeKeys) || !ownsWhatItHolds(node, nodeKeys)) return false + if (!isRecord(node) || !holdsOnly(node, nodeKeys)) return false if (typeof node['type'] !== 'string') return false if ('attrs' in node && !isAttributes(node['attrs'])) return false if ('marks' in node && !isArrayOf(node['marks'], isAdfMark)) return false @@ -219,11 +219,6 @@ function extraKey(value: Record, keys: readonly string[]): stri return Object.keys(value).find((key) => !keys.includes(key)) } -// holdsItself walks own enumerable keys alone, so a guard reading any other key could walk a cycle forever. -function ownsWhatItHolds(value: Record, keys: readonly string[]): boolean { - return keys.every((key) => !(key in value) || Object.prototype.propertyIsEnumerable.call(value, key)) -} - function holdsOnly(value: Record, keys: readonly string[]): boolean { return extraKey(value, keys) === undefined } -- 2.52.0 From 9f23c89719a04c2397a36b5492ab48e6a6ab011a Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:21:11 +0200 Subject: [PATCH 13/18] Answer stability round 4: the decision's title backs the loop guarantee alone, item 66 filed --- AGENTS.md | 2 +- README.md | 4 ++-- docs/decisions.md | 13 ++++++------- todo.md | 10 +++++++++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 504edfd..b0d3f6f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,7 @@ In `docs/decisions.md`: - The flavour spec is read as a source - The node tables answer to Atlassian's schema - Nothing recurses unbounded -- The guards take what `JSON.parse` or `structuredClone` builds +- No value `JSON.parse` or `structuredClone` builds loops a call - Nothing spreads an unbounded array - A retry loop checks its own termination - Readers scan by index diff --git a/README.md b/README.md index 3137576..2b2c6d2 100644 --- a/README.md +++ b/README.md @@ -238,8 +238,8 @@ Serves Goals 1, 3 and 4. input. - 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`. -- A document nested deeper than 500 levels is an error result, not a stack overflow, and no value - `JSON.parse` or `structuredClone` builds makes a call loop forever. +- A document nested deeper than 500 levels is an error result, not a stack overflow, and no call + loops forever on a string, or on a value `JSON.parse` or `structuredClone` builds. - 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)). - **`0.2.0`** — `htmlToAdf(adfToHtml(doc))` deep-equals `doc`; fidelity HTML cannot express rides diff --git a/docs/decisions.md b/docs/decisions.md index 5ca97cd..c5c18a2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -563,15 +563,14 @@ 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 once doubled the parser's frames per level. -## The guards take what `JSON.parse` or `structuredClone` builds +## No value `JSON.parse` or `structuredClone` builds loops a call -2026-10-04, the maintainer. Goal 1. Valid while every persona hands in a value `JSON.parse`, -`structuredClone` or an object literal builds. +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 guarantees speak of plain data: own enumerable keys and plain arrays, cycles and shared objects -included. The guards walk it by `Object.values` and refuse a cycle. 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, and -each guarded shape asked for another. +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 diff --git a/todo.md b/todo.md index a367e4f..8b0ced3 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 66` +`Next ID = 67` | Goal | W | |---|---| @@ -29,6 +29,7 @@ | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 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 | +| 66 | 0.2.0 | | **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 | | 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 | @@ -101,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 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. Today `markdownToAdf` reads CommonMark and the lossless flavour as one input: text shaped like a -- 2.52.0 From 2f0ab4dda9206aa69ea2ff36d7d204ea019b5fda Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:21:11 +0200 Subject: [PATCH 14/18] Answer product-owner round 4: the guarantee names strings -- 2.52.0 From e8733068abf61fab0f71a279c2b8039a83cef3f1 Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:21:11 +0200 Subject: [PATCH 15/18] Answer prose round 4: the premise says plain data, Goal 8's size is stated once -- 2.52.0 From 758549ab2495a64f44ad0598a50bd2ef893ca8ac Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:35:10 +0200 Subject: [PATCH 16/18] Answer product-owner round 5: item 66 is a defect --- todo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/todo.md b/todo.md index 8b0ced3..5b1ebc7 100644 --- a/todo.md +++ b/todo.md @@ -29,7 +29,7 @@ | 45 | 0.2.0 | | **Replace `isAdfDocument` with a reader returning `Result`.** | 2 | 3 | 6 | 8 | 1 | 25.2 | | 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 | -| 66 | 0.2.0 | | **Refuse an attribute value that is not a plain object, array or JSON primitive.** | 3 | 2 | 5 | 8 | 1 | 22.5 | +| 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 | | 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 | -- 2.52.0 From 5a9fd069741c9e7506c075033857de9102df04ee Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:35:10 +0200 Subject: [PATCH 17/18] Answer prose round 5: nits declined -- 2.52.0 From bfbe4a51fb613efbe6ee8d38e8936a171dc740ec Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Sun, 4 Oct 2026 19:35:10 +0200 Subject: [PATCH 18/18] Answer stability round 5: clean -- 2.52.0