From 67bdc3ca44bfa0578e1ddd32783bddf9a03c765c Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Fri, 9 Oct 2026 13:21:44 +0200 Subject: [PATCH] Answer stability round 1: a task list nested beside a task item of paragraphs stays nested when written, the property checks every block task item holds paragraphs alone, item 49 names the checkbox loss it closes, and item 93 files the stale refusal path --- src/conformance/adf-property.test.ts | 13 ++++++++++++- src/markdown/parse/portable-markdown-to-adf.test.ts | 3 +++ .../portable/adf-to-portable-markdown.test.ts | 2 ++ src/markdown/portable/adf-to-portable-markdown.ts | 5 +---- todo.md | 13 +++++++++++-- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/conformance/adf-property.test.ts b/src/conformance/adf-property.test.ts index 8fc1786..cbf9328 100644 --- a/src/conformance/adf-property.test.ts +++ b/src/conformance/adf-property.test.ts @@ -25,6 +25,16 @@ function shownText(nodes: readonly AdfNode[]): string[] { return shown.filter((text) => text !== '') } +function heldByBlockTaskItems(nodes: readonly AdfNode[]): string[] { + const held: string[] = [] + for (const node of nodes) { + const content = node.content ?? [] + if (node.type === 'blockTaskItem') for (const child of content) held.push(child.type) + for (const type of heldByBlockTaskItems(content)) held.push(type) + } + return held +} + test('a generated document emits markdown that reads back to it', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { @@ -38,13 +48,14 @@ test('a generated document emits markdown that reads back to it', { timeout: pro ) }) -test('a generated document writes portable markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself', { timeout: propertyTimeout }, () => { +test('a generated document writes portable markdown refusing only what the guard refuses, and that markdown reads back to its text and to itself, a block task item holding paragraphs alone', { timeout: propertyTimeout }, () => { fc.assert( fc.property(adfDocument, (document) => { const written = adfToPortableMarkdown(document) assert.ok(written.ok, written.ok ? '' : `${written.error.code}: ${written.error.message}`) const read = portableMarkdownToAdf(written.value) assert.ok(read.ok, read.ok ? '' : `${read.error.code}: ${read.error.message} — reading ${JSON.stringify(written.value)}`) + assert.deepEqual(heldByBlockTaskItems(read.value.content ?? []).filter((type) => type !== 'paragraph'), [], `reading ${JSON.stringify(written.value)}`) const reduced = reduceToPortable(document) assert.deepEqual(shownText(read.value.content ?? []), reduced.ok ? shownText(reduced.value.content ?? []) : reduced, `reading ${JSON.stringify(written.value)}`) assert.deepEqual(adfToPortableMarkdown(read.value), written, `reading ${JSON.stringify(written.value)}`) diff --git a/src/markdown/parse/portable-markdown-to-adf.test.ts b/src/markdown/parse/portable-markdown-to-adf.test.ts index 86e95f8..32c546a 100644 --- a/src/markdown/parse/portable-markdown-to-adf.test.ts +++ b/src/markdown/parse/portable-markdown-to-adf.test.ts @@ -148,6 +148,9 @@ test('reads a bullet list whose every item leads with a task marker to a task li test('moves a nested task list beside its item and makes an item holding more than one paragraph a block task item', () => { assert.deepEqual(read('- [x] Parent\n - [ ] Child\n- [ ] Next\n'), [bare('taskList', task('DONE', text('Parent')), bare('taskList', task('TODO', text('Child'))), task('TODO', text('Next')))]) assert.deepEqual(read('- [x] First.\n\n Second.\n- [ ] Next\n'), [bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')), task('TODO', text('Next')))]) + const nested = bare('taskList', node('blockTaskItem', { state: 'DONE' }, said('p1'), said('p2')), bare('taskList', task('TODO', text('n'))), task('TODO', text('B'))) + assert.deepEqual(read('- [x] p1\n\n p2\n - [ ] n\n- [ ] B\n'), [nested]) + assert.deepEqual(roundTripped(nested), [nested]) }) test('stands a block a task item cannot hold, and what follows it in the item, after the task list, which resumes at the next task', () => { diff --git a/src/markdown/portable/adf-to-portable-markdown.test.ts b/src/markdown/portable/adf-to-portable-markdown.test.ts index 55e71ca..bb3120d 100644 --- a/src/markdown/portable/adf-to-portable-markdown.test.ts +++ b/src/markdown/portable/adf-to-portable-markdown.test.ts @@ -132,6 +132,8 @@ test('spells a task list as a bullet list whose items lead with their state', () const blockTask = node('blockTaskItem', { state: 'DONE' }, said('First.'), said('Second.')) const codeTask = node('blockTaskItem', { state: 'TODO' }, { content: [text('x')], type: 'codeBlock' }) assert.equal(portable(node('taskList', {}, blockTask, codeTask)), '- [x] First.\n\n Second.\n- [ ]\n\n```\nx\n```\n') + const parent = node('blockTaskItem', { state: 'TODO' }, said('q'), said('q2')) + assert.equal(portable(node('taskList', {}, parent, node('taskList', {}, task('DONE', 'n')), task('DONE', 'B'))), '- [ ] q\n\n q2\n - [x] n\n- [x] B\n') const listTask = node('blockTaskItem', { state: 'DONE' }, bulletList(item(said('a')))) assert.equal(portable(node('taskList', {}, task('TODO', ''), listTask, node('taskList', {}, task('TODO', 'b')))), '- \\[ ]\n- \\[x]\n- a\n- \\[ ] b\n') assert.equal(portable(node('taskList', {}, task('DONE', 'a'), node('taskList', {}, task('TODO', '')))), '- [x] a\n - [ ]\n') diff --git a/src/markdown/portable/adf-to-portable-markdown.ts b/src/markdown/portable/adf-to-portable-markdown.ts index 7fe393c..03e854c 100644 --- a/src/markdown/portable/adf-to-portable-markdown.ts +++ b/src/markdown/portable/adf-to-portable-markdown.ts @@ -314,11 +314,8 @@ function reduceTaskList(node: AdfNode, reduction: Reduction): Result return success(listOf(nodeContent(tasksAsText({ content: tasks, type: 'taskList' })), 'bulletList')) } -// The writer nests a list in the task before it, so one closing a block task item's blocks merges with it. function nestIn(tasks: AdfNode[], nested: readonly AdfNode[]): void { - const previous = tasks.at(-1) - if (previous?.type === 'blockTaskItem') tasks[tasks.length - 1] = { ...previous, content: mergedLists([...nodeContent(previous), ...nested]) } - else for (const block of mergedLists(nested)) tasks.push(block) + for (const block of mergedLists(nested)) tasks.push(block) } function isTask(node: AdfNode | undefined): boolean { diff --git a/todo.md b/todo.md index 166422b..6258d2f 100644 --- a/todo.md +++ b/todo.md @@ -6,7 +6,7 @@ `Bar = 9` -`Next ID = 93` +`Next ID = 94` | Goal | W | |---|---| @@ -47,6 +47,7 @@ | 75 | 0.3.0 | | **Make the generated-markdown property write block carries as `adf:` fences, canonical and hostile.** | 2 | 2 | 3 | 7 | 1 | 17.5 | | 81 | 0.3.0 | | **Fail the gate unless the README's error tables list exactly the codes `ConvertErrorCode` holds.** | 1 | 2 | 5 | 5 | 3 | 16.3 | | 46 | 0.3.0 | | **Publish the bundle size in the README, failing the release pipeline when it drifts.** | 2 | 4 | 5 | 5 | 3, 8 | 14.8 | +| 93 | 0.3.0 | | **Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item.** | 2 | 3 | 3 | 5 | 1 | 13.2 | | 72 | 0.3.0 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 2 | 1 | 3 | 6 | 3 | 13.1 | | 52 | 0.3.0 | | **Spell `colwidth` as a comma list, `colwidth="340,420"`.** | 3 | 3 | 5 | 6 | 6 | 11.5 | | 51 | 0.3.0 | | **Match a reference label to its definition under Unicode case folding.** | 2 | 2 | 2 | 7 | 4, 5 | 10.9 | @@ -74,7 +75,8 @@ Today `- a` then `+ b`, or `1.` then `1)`, reads as one list; CommonMark reads t 301 and 302). Goal 5 settles it for the lossless flavour too: CommonMark spells adjacent lists by changing the marker, so `adfToLosslessMarkdown` and `adfToPortableMarkdown` alternate `-` and `*` between adjacent bullet lists, a task list among them, and `.` and `)` between adjacent ordered -lists, and `!adf:listBreak` retires. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and +lists, and `!adf:listBreak` retires. Until then, a bullet list standing after a task list joins +it when written, so `adfToPortableMarkdown` writes every task's marker as text. `*` is the maintainer's pick (2026-10-04). Breaking: `MIGRATION.md`'s Readings and Spellings tables gain their rows. Examples 301 and 302 lose their `pending` exceptions, and the spelling leaves the README's "Seven CommonMark spellings" bullet, which counts one fewer. @@ -249,6 +251,13 @@ or report the unminified gzip). The figure lands in README §The package beside dependencies" claim. Measured today, unminified: tarball 60.4 kB, unpacked 221.5 kB, JS gzipped 45.6 kB. +### 93. Report a refusal's `path` where the node would stand after `portableMarkdownToAdf` moves it out of a task item. + +`readBlocks` fixes a block's path before `splitTaskList` and `bulletNode` move blocks beside or +after their task, so `- [x] A\n\n ```\n x\n ```\n\n
x
\n` reports +`/content/0/content/0/content/2` for a block that would stand at `/content/2`. The README says +`path` is the node's place from the document root. + ### 72. Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub. Lands after every other `0.3.0` item (the maintainer, 2026-10-05). The maintainer does this. Gitea